From fccd95b12b79071cfcca87cf98920e8d9cc05cdf Mon Sep 17 00:00:00 2001 From: Zheng Wengang Date: Thu, 4 Jun 2026 23:32:58 +0800 Subject: [PATCH 01/75] [BugFix] Fix incorrect GPU device mapping in multi-replica stages (#4132) Signed-off-by: ZhengWG --- tests/entrypoints/test_stage_utils.py | 28 ++++++++++++--------------- vllm_omni/entrypoints/stage_utils.py | 18 ----------------- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/tests/entrypoints/test_stage_utils.py b/tests/entrypoints/test_stage_utils.py index f31bf3f20..05593d647 100644 --- a/tests/entrypoints/test_stage_utils.py +++ b/tests/entrypoints/test_stage_utils.py @@ -135,22 +135,6 @@ def test_set_stage_devices_npu_platform(mocker: MockerFixture, monkeypatch: pyte # ---- _map_device_list unit tests ---- -@pytest.mark.core_model -@pytest.mark.cpu -def test_map_device_list_idempotency(): - """Device IDs already in the visible set are returned as-is (idempotency).""" - result = _map_device_list(0, ["0", "1"], ["0", "1", "2", "3"]) - assert result == ["0", "1"] - - -@pytest.mark.core_model -@pytest.mark.cpu -def test_map_device_list_idempotency_single(): - """Single device ID in visible set passes through.""" - result = _map_device_list(1, ["1"], ["0", "1"]) - assert result == ["1"] - - @pytest.mark.core_model @pytest.mark.cpu def test_map_device_list_index_mapping(): @@ -211,6 +195,18 @@ def test_map_device_list_partial_mapping(): assert result == ["5", "6"] +@pytest.mark.core_model +@pytest.mark.cpu +def test_map_device_list_multi_replica_offset_cvd(): + """Logical indices from split_devices_for_replicas must map through an offset + CUDA_VISIBLE_DEVICES, not be treated as physical IDs. + """ + visible = ["2", "3", "4", "5"] + assert _map_device_list(1, ["1"], visible) == ["3"] + assert _map_device_list(1, ["2"], visible) == ["4"] + assert _map_device_list(1, ["2", "3"], visible) == ["4", "5"] + + @pytest.mark.core_model @pytest.mark.cpu def test_map_device_list_raises_on_non_numeric(): diff --git a/vllm_omni/entrypoints/stage_utils.py b/vllm_omni/entrypoints/stage_utils.py index e7c91cf4d..74b533b51 100644 --- a/vllm_omni/entrypoints/stage_utils.py +++ b/vllm_omni/entrypoints/stage_utils.py @@ -95,24 +95,6 @@ def _map_device_list(stage_id: int, device_list: list[str], visible_device_list: if not all(device.isdigit() for device in device_list): raise ValueError("Logical devices must be non-negative integers") - # Idempotency: if every requested id already names an entry in the visible - # pool (by value, not by index), treat the YAML field as physical and skip - # the remap. This avoids a double-mapping crash when a parent harness has - # already narrowed CUDA_VISIBLE_DEVICES to specific physical ids before - # spawning the subprocess (the test runtime's OmniServerStageCli does this - # — it sets each stage subprocess's env via ``_set_stage_device_env``). - # Without this guard, a YAML ``devices: "1"`` against a narrowed visible - # list ``["1"]`` would try ``visible[1]`` (out of range) and raise. - visible_set = set(visible_device_list) - if all(device in visible_set for device in device_list): - logger.info( - "Stage %s requested devices %s are already in the visible set %s; using as-is (no remap).", - stage_id, - device_list, - visible_device_list, - ) - return list(device_list) - logical_ids = [int(device) for device in device_list] # If ALL logical device IDs are >= num_visible, none can be 0-based indices From 31f882935d8f3e9b5de867f1e914ce6164a356a5 Mon Sep 17 00:00:00 2001 From: "Chendi.Xue" Date: Thu, 4 Jun 2026 17:20:16 -0500 Subject: [PATCH 02/75] [XPU][S2V] Optimize Wan2.2 S2V: RoPE refactor + cache_dit enabling (#4062) Signed-off-by: Chendi Xue Co-authored-by: Mengkejiergeli Ba --- docs/user_guide/diffusion_features.md | 1 + .../speech_to_video/speech_to_video.py | 82 +- .../diffusion/cache/cache_dit_backend.py | 220 +++++ vllm_omni/diffusion/diffusion_engine.py | 5 + vllm_omni/diffusion/layers/rope.py | 232 ++++++ .../models/wan2_2/pipeline_wan2_2_s2v.py | 44 +- .../models/wan2_2/wan2_2_s2v_transformer.py | 771 ++++++++---------- 7 files changed, 841 insertions(+), 514 deletions(-) diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 3c58c1181..945f83caa 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -145,6 +145,7 @@ The following tables show which models support each feature: | Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | Pipeline-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution | |------------------------------|:---------:|:----------:|:---------------------:|:--------------:|:-----------------:|:-----------------:|:------:|:-------------------------:|:--------------------:|:--------------:|:----------------:| | **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ | +| **Wan2.2-S2V** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | diff --git a/examples/offline_inference/speech_to_video/speech_to_video.py b/examples/offline_inference/speech_to_video/speech_to_video.py index 97ce63700..d783ee2f7 100644 --- a/examples/offline_inference/speech_to_video/speech_to_video.py +++ b/examples/offline_inference/speech_to_video/speech_to_video.py @@ -16,9 +16,10 @@ Usage: """ import argparse -import os +import json import time from pathlib import Path +from typing import Any import torch @@ -29,6 +30,16 @@ from vllm_omni.outputs import OmniRequestOutput from vllm_omni.platforms import current_omni_platform +def parse_profiler_config(value: str) -> dict[str, Any]: + try: + config = json.loads(value) + except json.JSONDecodeError as e: + raise argparse.ArgumentTypeError(f"--profiler-config must be valid JSON: {e}") from e + if not isinstance(config, dict): + raise argparse.ArgumentTypeError("--profiler-config must be a JSON object") + return config + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Generate a talking-head video from a reference image and audio (Wan2.2 S2V)." @@ -87,6 +98,12 @@ def parse_args() -> argparse.Namespace: default=3.0, help="Scheduler flow shift (default: 3.0).", ) + parser.add_argument( + "--boundary-ratio", + type=float, + default=None, + help="Boundary split ratio for low/high DiT stages (S2V). Default varies by model.", + ) parser.add_argument( "--output", type=str, @@ -178,30 +195,10 @@ def parse_args() -> argparse.Namespace: help="Enable cache-dit summary logging after diffusion forward passes.", ) parser.add_argument( - "--profile-dir", - type=str, + "--profiler-config", + type=parse_profiler_config, default=None, - help="Enable torch profiler and save traces to this directory.", - ) - parser.add_argument( - "--profile-record-shapes", - action="store_true", - help="Record tensor shapes in profiler (increases trace size).", - ) - parser.add_argument( - "--profile-with-stack", - action="store_true", - help="Record stack traces in profiler (increases overhead).", - ) - parser.add_argument( - "--profile-with-memory", - action="store_true", - help="Profile memory usage.", - ) - parser.add_argument( - "--profile-with-flops", - action="store_true", - help="Estimate FLOPs for operations.", + help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.', ) return parser.parse_args() @@ -239,23 +236,9 @@ def main(): ulysses_degree=args.ulysses_degree, ) - # Check if profiling is requested via CLI args or environment variable - profile_dir = args.profile_dir or os.getenv("VLLM_TORCH_PROFILER_DIR") - profiler_enabled = bool(profile_dir) - profiler_config = None - if profiler_enabled: - from vllm.config import ProfilerConfig + profiler_enabled = args.profiler_config is not None - profiler_config = ProfilerConfig( - profiler="torch", - torch_profiler_dir=profile_dir, - torch_profiler_record_shapes=args.profile_record_shapes, - torch_profiler_with_stack=args.profile_with_stack, - torch_profiler_with_memory=args.profile_with_memory, - torch_profiler_with_flops=args.profile_with_flops, - ) - - omni = Omni( + omni_kwargs = dict( model=args.model, model_class_name="WanS2VPipeline", flow_shift=args.flow_shift, @@ -269,8 +252,12 @@ def main(): cache_backend=args.cache_backend, cache_config=cache_config, enable_cache_dit_summary=args.enable_cache_dit_summary, - profiler_config=profiler_config, + profiler_config=args.profiler_config, ) + if args.boundary_ratio is not None: + omni_kwargs["boundary_ratio"] = args.boundary_ratio + + omni = Omni(**omni_kwargs) # Print generation configuration print(f"\n{'=' * 60}") @@ -325,13 +312,16 @@ def main(): if profiler_enabled: print("\n[Profiler] Stopping profiler and collecting results...") profile_results = omni.stop_profile() - if profile_results and isinstance(profile_results, list): + if profile_results and isinstance(profile_results, dict): + traces = profile_results.get("traces", []) print("\n" + "=" * 60) print("PROFILING RESULTS:") - for i, result in enumerate(profile_results): - print(f"\nStage {i}:") - if result: - print(f" {result}") + for rank, trace in enumerate(traces): + print(f"\nRank {rank}:") + if trace: + print(f" • Trace: {trace}") + if not traces: + print(" No traces collected.") print("=" * 60) else: print("[Profiler] No valid profiling data returned.") diff --git a/vllm_omni/diffusion/cache/cache_dit_backend.py b/vllm_omni/diffusion/cache/cache_dit_backend.py index 6a2600e9f..ff1eea6ae 100644 --- a/vllm_omni/diffusion/cache/cache_dit_backend.py +++ b/vllm_omni/diffusion/cache/cache_dit_backend.py @@ -242,6 +242,80 @@ def enable_cache_for_wan22(pipeline: Any, cache_config: Any) -> Callable[[int], return refresh_cache_context +def enable_cache_for_wan22_s2v(pipeline: Any, cache_config: Any) -> Callable[[int], None]: + """Enable cache-dit for Wan2.2 S2V. + + S2V uses a single transformer, but unlike the other Wan2.2 variants its + block loop calls each block as ``block(hidden_states, **kwargs)`` and keeps + the timestep modulation state in ``e`` rather than a second positional + tensor. CacheDiT Pattern_3 matches that contract: cache hidden states only + and pass the remaining conditioning through kwargs unchanged. + + The S2V transformer has an ``after_transformer_block`` method that injects + audio embeddings after specific layers. The cached blocks wrapper + (Wan22S2VCachedBlocks._run_block) calls the original internally, so we + permanently replace it with a no-op on the transformer to prevent double + injection from the main forward loop. + """ + db_cache_config = _build_db_cache_config(cache_config) + calibrator_config = None + if cache_config.enable_taylorseer: + taylorseer_order = cache_config.taylorseer_order + calibrator_config = TaylorSeerCalibratorConfig(taylorseer_order=taylorseer_order) + logger.info(f"TaylorSeer enabled with order={taylorseer_order}") + + # Save the original after_transformer_block before cache-dit wrapping + transformer = pipeline.transformer + if hasattr(transformer, "after_transformer_block"): + transformer._cache_dit_original_after_transformer_block = transformer.after_transformer_block + + Wan22S2VCachedAdapter.apply( + BlockAdapter( + transformer=transformer, + blocks=[transformer.blocks], + forward_pattern=[ForwardPattern.Pattern_3], + params_modifiers=[ + ParamsModifier(cache_config=db_cache_config, calibrator_config=calibrator_config), + ], + has_separate_cfg=True, + ), + cache_config=db_cache_config, + calibrator_config=calibrator_config, + ) + + # Permanently replace after_transformer_block with a no-op. + # The cached blocks wrapper (Wan22S2VCachedBlocks._run_block) already calls + # the original via _cache_dit_original_after_transformer_block. + def _noop_after_transformer_block(block_idx: int, hidden_states: torch.Tensor) -> torch.Tensor: + return hidden_states + + transformer.after_transformer_block = _noop_after_transformer_block + + def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: + """Refresh cache context for the S2V transformer.""" + if cache_config.scm_steps_mask_policy is None: + cache_dit.refresh_context( + pipeline.transformer, + num_inference_steps=num_inference_steps, + verbose=verbose, + ) + else: + cache_dit.refresh_context( + pipeline.transformer, + cache_config=DBCacheConfig().reset( + num_inference_steps=num_inference_steps, + steps_computation_mask=cache_dit.steps_mask( + mask_policy=cache_config.scm_steps_mask_policy, + total_steps=num_inference_steps, + ), + steps_computation_policy=cache_config.scm_steps_policy, + ), + verbose=verbose, + ) + + return refresh_cache_context + + def enable_cache_for_longcat_image(pipeline: Any, cache_config: Any) -> Callable[[int], None]: """Enable cache-dit for LongCatImage pipeline. @@ -986,6 +1060,151 @@ class BagelCachedBlocks(CachedBlocks_Pattern_0_1_2): return hidden_states, encoder_hidden_states +class Wan22S2VCachedBlocks(CachedBlocks_Pattern_3_4_5): + """CacheDiT blocks wrapper that preserves S2V per-layer audio injection.""" + + def _run_block(self, block_id: int, block: torch.nn.Module, hidden_states: torch.Tensor, *args, **kwargs): + hidden_states = block(hidden_states, *args, **kwargs) + hidden_states, new_encoder_hidden_states = self._process_block_outputs(hidden_states) + original_after_transformer_block = getattr( + self.transformer, + "_cache_dit_original_after_transformer_block", + getattr(self.transformer, "after_transformer_block", None), + ) + if original_after_transformer_block is not None: + hidden_states = original_after_transformer_block(block_id, hidden_states) + return hidden_states, new_encoder_hidden_states + + def call_blocks( + self, + hidden_states: torch.Tensor, + *args, + **kwargs, + ): + new_encoder_hidden_states = None + for block_id, block in enumerate(self.transformer_blocks): + hidden_states, new_encoder_hidden_states = self._run_block( + block_id, + block, + hidden_states, + *args, + **kwargs, + ) + return hidden_states, new_encoder_hidden_states + + def call_Fn_blocks( + self, + hidden_states: torch.Tensor, + *args, + **kwargs, + ): + new_encoder_hidden_states = None + for block_id, block in enumerate(self._Fn_blocks()): + hidden_states, new_encoder_hidden_states = self._run_block( + block_id, + block, + hidden_states, + *args, + **kwargs, + ) + return hidden_states, new_encoder_hidden_states + + def call_Mn_blocks( + self, + hidden_states: torch.Tensor, + *args, + **kwargs, + ): + original_hidden_states = hidden_states + new_encoder_hidden_states = None + start_idx = self.context_manager.Fn_compute_blocks() + for block_id, block in enumerate(self._Mn_blocks(), start=start_idx): + hidden_states, new_encoder_hidden_states = self._run_block( + block_id, + block, + hidden_states, + *args, + **kwargs, + ) + + hidden_states = hidden_states.contiguous() + hidden_states_residual = hidden_states - original_hidden_states.to(hidden_states.device) + + return ( + hidden_states, + new_encoder_hidden_states, + hidden_states_residual, + ) + + def call_Bn_blocks( + self, + hidden_states: torch.Tensor, + *args, + **kwargs, + ): + new_encoder_hidden_states = None + if self.context_manager.Bn_compute_blocks() == 0: + return hidden_states, new_encoder_hidden_states + + start_idx = len(self.transformer_blocks) - self.context_manager.Bn_compute_blocks() + for block_id, block in enumerate(self._Bn_blocks(), start=start_idx): + hidden_states, new_encoder_hidden_states = self._run_block( + block_id, + block, + hidden_states, + *args, + **kwargs, + ) + + return hidden_states, new_encoder_hidden_states + + +class Wan22S2VCachedAdapter(CachedAdapter): + """CacheDiT adapter that uses Wan22S2VCachedBlocks for S2V audio injection. + + Only overrides collect_unified_blocks to use Wan22S2VCachedBlocks (which + calls after_transformer_block per-layer internally). The base class + mock_transformer handles the forward wrapping — after_transformer_block is + permanently replaced with a no-op in enable_cache_for_wan22_s2v() to prevent + double injection. + """ + + @classmethod + def collect_unified_blocks( + cls, + block_adapter: BlockAdapter, + contexts_kwargs: list[dict], + ) -> list[dict[str, torch.nn.ModuleList]]: + BlockAdapter.assert_normalized(block_adapter) + + total_cached_blocks: list[dict[str, torch.nn.ModuleList]] = [] + assert hasattr(block_adapter.pipe, "_context_manager") + + for i in range(len(block_adapter.transformer)): + unified_blocks_bind_context = {} + for j in range(len(block_adapter.blocks[i])): + cache_config: BasicCacheConfig = contexts_kwargs[i * len(block_adapter.blocks[i]) + j]["cache_config"] + unified_blocks_bind_context[block_adapter.unique_blocks_name[i][j]] = torch.nn.ModuleList( + [ + Wan22S2VCachedBlocks( + block_adapter.blocks[i][j], + transformer=block_adapter.transformer[i], + forward_pattern=block_adapter.forward_pattern[i][j], + check_forward_pattern=block_adapter.check_forward_pattern, + check_num_outputs=block_adapter.check_num_outputs, + cache_prefix=block_adapter.blocks_name[i][j], + cache_context=block_adapter.unique_blocks_name[i][j], + context_manager=block_adapter.pipe._context_manager, + cache_type=cache_config.cache_type, + ) + ] + ) + + total_cached_blocks.append(unified_blocks_bind_context) + + return total_cached_blocks + + class BagelCachedAdapter(CachedAdapter): """ Custom CachedAdapter for Bagel that uses BagelCachedContextManager and BagelCachedBlocks. @@ -1808,6 +2027,7 @@ CUSTOM_DIT_ENABLERS.update( { "Wan22Pipeline": enable_cache_for_wan22, "Wan22I2VPipeline": enable_cache_for_wan22, + "Wan22S2VPipeline": enable_cache_for_wan22_s2v, "HunyuanImage3Pipeline": enable_cache_for_hunyuan_image3, "FluxPipeline": enable_cache_for_flux, "Flux2KleinPipeline": enable_cache_for_flux2_klein, diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index 54e93c718..4e3853a2f 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -96,6 +96,8 @@ def _move_tensor_tree_to_cpu(value: object) -> object: def get_dummy_run_num_frames(model_class_name: str, supports_audio_input: bool) -> int: + """Get num_frames for the dummy warmup run. Returns 0 to skip warmup.""" + model_cls = DiffusionModelRegistry._try_load_model_cls(model_class_name) if model_cls is not None and hasattr(model_cls, "dummy_run_num_frames"): return int(getattr(model_cls, "dummy_run_num_frames")) @@ -722,6 +724,9 @@ class DiffusionEngine: prompt.setdefault("multi_modal_data", {})["audio"] = dummy_audio num_frames = get_dummy_run_num_frames(self.od_config.model_class_name, supports_audio_input) + if num_frames <= 0: + logger.info("Skipping dummy warmup run (num_frames=0)") + return req = OmniDiffusionRequest( prompts=[prompt], request_id=DUMMY_DIFFUSION_REQUEST_ID, diff --git a/vllm_omni/diffusion/layers/rope.py b/vllm_omni/diffusion/layers/rope.py index b4fa1209f..4cb714465 100644 --- a/vllm_omni/diffusion/layers/rope.py +++ b/vllm_omni/diffusion/layers/rope.py @@ -5,6 +5,7 @@ from einops import rearrange, repeat from vllm.logger import init_logger from vllm_omni.diffusion.layers.custom_op import CustomOp +from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) @@ -300,3 +301,234 @@ def apply_rope_to_qk( query = rope(query, cos, sin) key = rope(key, cos, sin) return query, key + + +class WanS2VRotaryPosEmbed(torch.nn.Module): + """Precompute complex-valued RoPE embeddings for S2V multi-grid positions. + + Owns the base frequency buffer and provides forward() to compute position + embeddings given hidden_states (for shape) and grid_sizes. + """ + + def __init__(self, num_heads: int, head_dim: int, max_seq_len: int = 1024): + super().__init__() + self.num_heads = num_heads + self.head_dim = head_dim + d = head_dim + freqs = torch.cat( + [ + self._rope_params(max_seq_len, d - 4 * (d // 6)), + self._rope_params(max_seq_len, 2 * (d // 6)), + self._rope_params(max_seq_len, 2 * (d // 6)), + ], + dim=1, + ) + self.register_buffer("freqs", freqs.to(torch.complex64), persistent=False) + + @staticmethod + @torch.amp.autocast(current_omni_platform.device_type, enabled=False) + def _rope_params(max_seq_len, dim, theta=10000): + if dim % 2 != 0: + raise ValueError(f"dim ({dim}) must be even") + freqs = torch.outer( + torch.arange(max_seq_len), 1.0 / torch.pow(theta, torch.arange(0, dim, 2).to(torch.float64).div(dim)) + ) + return torch.polar(torch.ones_like(freqs), freqs) + + def forward( + self, + hidden_states: torch.Tensor, + grid_sizes: list, + trainable_freqs: torch.Tensor | None = None, + ) -> torch.Tensor: + """Precompute RoPE embeddings for the given grid layout. + + Args: + hidden_states: Tensor [B, S, ...] (used for batch/seq shape and device) + grid_sizes: Grid specification (list of [offsets, sizes, totals]) + trainable_freqs: Optional trainable frequency overrides for t_f < 0 + + Returns: + Complex tensor [B, S, 1, head_dim//2] of precomputed position embeddings + """ + b, s = hidden_states.shape[0], hidden_states.shape[1] + c = self.head_dim // 2 + device = hidden_states.device + + freqs = self.freqs.to(device) + if trainable_freqs is not None: + freqs_input = [freqs, trainable_freqs] + else: + freqs_input = freqs + + if isinstance(freqs_input, list): + trainable_f = freqs_input[1] + freqs_split = freqs_input[0] + else: + trainable_f = None + freqs_split = freqs_input + freqs_split = freqs_split.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + output = torch.empty((b, s, 1, c), device=device, dtype=torch.complex64) + seq_bucket = [0] + if not isinstance(grid_sizes, list): + grid_sizes = [grid_sizes] + for g in grid_sizes: + if not isinstance(g, list): + g = [torch.zeros_like(g), g] + batch_size = g[0].shape[0] + for i in range(batch_size): + f_o, h_o, w_o = g[0][i] + f, h, w = g[1][i] + t_f, t_h, t_w = g[2][i] + seq_f, seq_h, seq_w = f - f_o, h - h_o, w - w_o + seq_len = int(seq_f * seq_h * seq_w) + if seq_len > 0: + if t_f > 0: + assert f_o * f >= 0 and h_o * h >= 0 and w_o * w >= 0 + seq_f_int = int(seq_f) + seq_h_int = int(seq_h) + seq_w_int = int(seq_w) + + if f_o >= 0: + f_sam = torch.linspace(int(f_o), int(t_f + f_o) - 1, seq_f_int, device=device).long() + else: + f_sam = torch.linspace(int(-f_o), int(-t_f - f_o) + 1, seq_f_int, device=device).long() + h_sam = torch.linspace(int(h_o), int(t_h + h_o) - 1, seq_h_int, device=device).long() + w_sam = torch.linspace(int(w_o), int(t_w + w_o) - 1, seq_w_int, device=device).long() + + freqs_0 = freqs_split[0][f_sam] if f_o >= 0 else freqs_split[0][f_sam].conj() + freqs_0 = freqs_0.view(seq_f_int, 1, 1, -1) + + freqs_i = torch.cat( + [ + freqs_0.expand(seq_f_int, seq_h_int, seq_w_int, -1), + freqs_split[1][h_sam] + .view(1, seq_h_int, 1, -1) + .expand(seq_f_int, seq_h_int, seq_w_int, -1), + freqs_split[2][w_sam] + .view(1, 1, seq_w_int, -1) + .expand(seq_f_int, seq_h_int, seq_w_int, -1), + ], + dim=-1, + ).reshape(seq_len, 1, -1) + elif t_f < 0: + freqs_i = trainable_f.unsqueeze(1) + output[i, seq_bucket[-1] : seq_bucket[-1] + seq_len] = freqs_i + seq_bucket.append(seq_bucket[-1] + seq_len) + return output + + +class RotaryEmbeddingWanS2V(RotaryEmbeddingWan): + """Apply RoPE using precomputed complex freqs for Wan S2V main transformer. + + Converts complex freqs (from WanS2VRotaryPosEmbed) to cos/sin and delegates + to RotaryEmbeddingWan for platform-optimized application (float32 kernel). + Under TP, freqs has 1 head — broadcasts automatically via cos/sin. + """ + + def __init__(self) -> None: + super().__init__(is_neox_style=False, half_head_dim=True) + + def forward(self, x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor: + freqs_sliced = freqs[:, : x.size(1)] + cos = freqs_sliced.real.to(x.dtype) + sin = freqs_sliced.imag.to(x.dtype) + return super().forward(x, cos, sin) + + +class RotaryEmbeddingS2VGrid(torch.nn.Module): + """Grid-based RoPE for S2V motioner/init attention. + + Applies complex-valued rotary embeddings using 3D grid sampling + (frame, height, width). Used by SimpleSelfAttention, SwinSelfAttention, + CausalSelfAttention in motioner blocks. + """ + + @staticmethod + @torch.amp.autocast(current_omni_platform.device_type, enabled=False) + def precompute( + seq_len: int, num_heads: int, head_dim: int, grid_sizes, freqs: torch.Tensor, device: torch.device, start=None + ) -> torch.Tensor: + """Precompute position frequency tensor from grid specification. + + Returns a complex tensor that can be reused across layers via apply_precomputed(). + """ + c = head_dim // 2 + + trainable_freqs = None + if isinstance(freqs, list): + trainable_freqs = freqs[1] + freqs = freqs[0] + freqs = freqs.to(device) + freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) + + if not isinstance(grid_sizes, list): + grid_sizes = [grid_sizes] + + batch_size = grid_sizes[0][0].shape[0] if isinstance(grid_sizes[0], list) else grid_sizes[0].shape[0] + precomputed = torch.empty((batch_size, seq_len, 1, c), device=device, dtype=torch.complex64) + seq_bucket = [0] + + for g in grid_sizes: + if not isinstance(g, list): + g = [torch.zeros_like(g), g] + g_batch_size = g[0].shape[0] + for i in range(g_batch_size): + if start is None: + f_o, h_o, w_o = g[0][i] + else: + f_o, h_o, w_o = start[i] + + f, h, w = g[1][i] + t_f, t_h, t_w = g[2][i] + seq_f, seq_h, seq_w = f - f_o, h - h_o, w - w_o + seg_len = int(seq_f * seq_h * seq_w) + if seg_len > 0: + if t_f > 0: + seq_f_int = int(seq_f) + seq_h_int = int(seq_h) + seq_w_int = int(seq_w) + + if f_o >= 0: + f_sam = torch.linspace(int(f_o), int(t_f + f_o) - 1, seq_f_int, device=device).long() + else: + f_sam = torch.linspace(int(-f_o), int(-t_f - f_o) + 1, seq_f_int, device=device).long() + h_sam = torch.linspace(int(h_o), int(t_h + h_o) - 1, seq_h_int, device=device).long() + w_sam = torch.linspace(int(w_o), int(t_w + w_o) - 1, seq_w_int, device=device).long() + + freqs_0 = freqs[0][f_sam] if f_o >= 0 else freqs[0][f_sam].conj() + freqs_0 = freqs_0.view(seq_f_int, 1, 1, -1) + + freqs_i = torch.cat( + [ + freqs_0.expand(seq_f_int, seq_h_int, seq_w_int, -1), + freqs[1][h_sam].view(1, seq_h_int, 1, -1).expand(seq_f_int, seq_h_int, seq_w_int, -1), + freqs[2][w_sam].view(1, 1, seq_w_int, -1).expand(seq_f_int, seq_h_int, seq_w_int, -1), + ], + dim=-1, + ).reshape(seg_len, 1, -1) + elif t_f < 0: + freqs_i = trainable_freqs.unsqueeze(1) + precomputed[i, seq_bucket[-1] : seq_bucket[-1] + seg_len] = freqs_i + seq_bucket.append(seq_bucket[-1] + seg_len) + return precomputed + + @staticmethod + @torch.amp.autocast(current_omni_platform.device_type, enabled=False) + def apply_precomputed(x: torch.Tensor, precomputed_freqs: torch.Tensor) -> torch.Tensor: + """Apply precomputed position frequencies to input tensor.""" + n = x.size(2) + input_dtype = x.dtype + seq_len = x.size(1) + precomputed_freqs = precomputed_freqs[:, :seq_len] + x_c = torch.view_as_complex(x.to(torch.float64).reshape(x.size(0), seq_len, n, -1, 2)) + x_c = torch.view_as_real(x_c * precomputed_freqs).flatten(3) + return x_c.reshape(x.size(0), seq_len, n, -1).to(input_dtype) + + @staticmethod + @torch.amp.autocast(current_omni_platform.device_type, enabled=False) + def forward(x: torch.Tensor, grid_sizes, freqs: torch.Tensor, start=None) -> torch.Tensor: + n, d = x.size(2), x.size(3) + precomputed = RotaryEmbeddingS2VGrid.precompute(x.size(1), n, d, grid_sizes, freqs, x.device, start=start) + return RotaryEmbeddingS2VGrid.apply_precomputed(x, precomputed) diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_s2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_s2v.py index 0817d6f48..1bd32bb42 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_s2v.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_s2v.py @@ -13,7 +13,7 @@ import logging import math import os from collections.abc import Iterable -from typing import Any +from typing import Any, ClassVar import numpy as np import PIL.Image @@ -482,6 +482,7 @@ class Wan22S2VPipeline( _DEFAULT_MOTION_FRAMES = 73 _DEFAULT_INFER_FRAMES = 80 _DEFAULT_FPS = 16 + dummy_run_num_frames: ClassVar[int] = 0 def __init__( self, @@ -929,21 +930,19 @@ class Wan22S2VPipeline( def predict_noise(self, current_model: nn.Module | None = None, **kwargs: Any) -> torch.Tensor: """Forward pass through the S2V transformer to predict noise. - The S2V model returns a list of tensors (one per batch element). - We take the first element since S2V processes one sample at a time. + With return_dict=False, the model returns (output,) where output is [B, C, T, H, W]. + We squeeze batch dim since S2V processes one sample at a time. """ if current_model is None: current_model = self.transformer - # WanModel_S2V's norm layers compute in float32; autocast ensures - # the float32→bfloat16 casts happen automatically (matching the - # original Wan2.2 inference path). param_dtype = next(current_model.parameters()).dtype with torch.amp.autocast(self.device.type, dtype=param_dtype): result = current_model(**kwargs) - # WanModel_S2V.forward returns a list of tensors if isinstance(result, list): return result[0] - return result[0] if isinstance(result, tuple) else result + if isinstance(result, tuple): + return result[0].squeeze(0) + return result.sample.squeeze(0) def diffuse( self, @@ -996,32 +995,29 @@ class Wan22S2VPipeline( latent_model_input = [latents.to(device)] timestep = t.unsqueeze(0).to(device) if t.dim() == 0 else t.to(device) - # -- Positive (conditional) prediction kwargs -- positive_kwargs = { - "x": latent_model_input, - "t": timestep, - "context": prompt_embeds[0:1], - "seq_len": max_seq_len, + "hidden_states": latent_model_input, + "timestep": timestep, + "encoder_hidden_states": prompt_embeds[0:1], "cond_states": cond_latents, "motion_latents": input_motion_latents, "ref_latents": ref_latents, - "motion_frames": motion_frames, "drop_motion_frames": drop_first_motion, - "audio_emb": positive_audio_emb, + "encoder_hidden_states_audio": positive_audio_emb, + "return_dict": False, } if do_true_cfg: negative_kwargs = { - "x": latent_model_input, - "t": timestep, - "context": negative_prompt_embeds[0:1], - "seq_len": max_seq_len, + "hidden_states": latent_model_input, + "timestep": timestep, + "encoder_hidden_states": negative_prompt_embeds[0:1], "cond_states": cond_latents, "motion_latents": input_motion_latents, "ref_latents": ref_latents, - "motion_frames": motion_frames, "drop_motion_frames": drop_first_motion, - "audio_emb": negative_audio_emb, + "encoder_hidden_states_audio": negative_audio_emb, + "return_dict": False, } else: negative_kwargs = None @@ -1174,7 +1170,7 @@ class Wan22S2VPipeline( ref_latents = self.encode_ref_image(image, height, width, device=device).to(dtype=dtype) # ---- 4. Initial motion latents (zeros) ---- - motion_frames = self.motion_frames + motion_frames = min(self.motion_frames, infer_frames - 1) motion_pixels = torch.zeros( [1, 3, motion_frames, height, width], dtype=dtype, @@ -1288,6 +1284,10 @@ class Wan22S2VPipeline( ) # ---- Decode this clip ---- + if self.od_config.enable_cpu_offload: + self.transformer.to("cpu") + current_omni_platform.empty_cache() + latents_for_decode = latents.unsqueeze(0) # [1, C, T, H, W] if not (drop_first_motion and r == 0): decode_latents = torch.cat([motion_latents, latents_for_decode], dim=2) 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 cc0bb78c1..8fef41f74 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 @@ -10,13 +10,13 @@ import math from collections.abc import Iterable from copy import deepcopy -import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from diffusers.models.attention import AdaLayerNorm +from diffusers.models.embeddings import PixArtAlphaTextProjection, TimestepEmbedding, Timesteps from diffusers.models.normalization import FP32LayerNorm -from einops import rearrange, repeat +from einops import rearrange from vllm.distributed import ( get_tensor_model_parallel_rank, get_tensor_model_parallel_world_size, @@ -31,6 +31,7 @@ from vllm.model_executor.layers.linear import ( from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.layers.rope import RotaryEmbeddingS2VGrid, RotaryEmbeddingWanS2V, WanS2VRotaryPosEmbed from vllm_omni.platforms import current_omni_platform from .wan2_2_transformer import DistributedRMSNorm, WanFeedForward @@ -53,170 +54,6 @@ def sinusoidal_embedding_1d(dim, position): return x -@torch.amp.autocast(current_omni_platform.device_type, enabled=False) -def rope_params(max_seq_len, dim, theta=10000): - if dim % 2 != 0: - raise ValueError(f"dim ({dim}) must be even") - freqs = torch.outer( - torch.arange(max_seq_len), 1.0 / torch.pow(theta, torch.arange(0, dim, 2).to(torch.float64).div(dim)) - ) - freqs = torch.polar(torch.ones_like(freqs), freqs) - return freqs - - -def rope_precompute(x, grid_sizes, freqs, start=None): - """Precompute complex-valued RoPE embeddings for S2V multi-grid positions.""" - b, s, n, c = x.size(0), x.size(1), x.size(2), x.size(3) // 2 - - if isinstance(freqs, list): - trainable_freqs = freqs[1] - freqs = freqs[0] - freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) - - output = torch.view_as_complex(x.detach().reshape(b, s, n, -1, 2).to(torch.float64)) - seq_bucket = [0] - if not isinstance(grid_sizes, list): - grid_sizes = [grid_sizes] - for g in grid_sizes: - if not isinstance(g, list): - g = [torch.zeros_like(g), g] - batch_size = g[0].shape[0] - for i in range(batch_size): - if start is None: - f_o, h_o, w_o = g[0][i] - else: - f_o, h_o, w_o = start[i] - - f, h, w = g[1][i] - t_f, t_h, t_w = g[2][i] - seq_f, seq_h, seq_w = f - f_o, h - h_o, w - w_o - seq_len = int(seq_f * seq_h * seq_w) - if seq_len > 0: - if t_f > 0: - if f_o >= 0: - f_sam = np.linspace(f_o.item(), (t_f + f_o).item() - 1, seq_f).astype(int).tolist() - else: - f_sam = np.linspace(-f_o.item(), (-t_f - f_o).item() + 1, seq_f).astype(int).tolist() - h_sam = np.linspace(h_o.item(), (t_h + h_o).item() - 1, seq_h).astype(int).tolist() - w_sam = np.linspace(w_o.item(), (t_w + w_o).item() - 1, seq_w).astype(int).tolist() - - assert f_o * f >= 0 and h_o * h >= 0 and w_o * w >= 0 - freqs_0 = freqs[0][f_sam] if f_o >= 0 else freqs[0][f_sam].conj() - freqs_0 = freqs_0.view(seq_f, 1, 1, -1) - - freqs_i = torch.cat( - [ - freqs_0.expand(seq_f, seq_h, seq_w, -1), - freqs[1][h_sam].view(1, seq_h, 1, -1).expand(seq_f, seq_h, seq_w, -1), - freqs[2][w_sam].view(1, 1, seq_w, -1).expand(seq_f, seq_h, seq_w, -1), - ], - dim=-1, - ).reshape(seq_len, 1, -1) - elif t_f < 0: - freqs_i = trainable_freqs.unsqueeze(1) - output[i, seq_bucket[-1] : seq_bucket[-1] + seq_len] = freqs_i - seq_bucket.append(seq_bucket[-1] + seq_len) - return output - - -@torch.amp.autocast(current_omni_platform.device_type, enabled=False) -def rope_apply(x, grid_sizes, freqs, start=None): - """Apply RoPE using grid-based frequency computation (for motioner/init).""" - n, c = x.size(2), x.size(3) // 2 - input_dtype = x.dtype - - if isinstance(freqs, list): - trainable_freqs = freqs[1] - freqs = freqs[0] - freqs = freqs.split([c - 2 * (c // 3), c // 3, c // 3], dim=1) - - output = x.clone() - seq_bucket = [0] - if not isinstance(grid_sizes, list): - grid_sizes = [grid_sizes] - for g in grid_sizes: - if not isinstance(g, list): - g = [torch.zeros_like(g), g] - batch_size = g[0].shape[0] - for i in range(batch_size): - if start is None: - f_o, h_o, w_o = g[0][i] - else: - f_o, h_o, w_o = start[i] - - f, h, w = g[1][i] - t_f, t_h, t_w = g[2][i] - seq_f, seq_h, seq_w = f - f_o, h - h_o, w - w_o - seq_len = int(seq_f * seq_h * seq_w) - if seq_len > 0: - if t_f > 0: - if f_o >= 0: - f_sam = np.linspace(f_o.item(), (t_f + f_o).item() - 1, seq_f).astype(int).tolist() - else: - f_sam = np.linspace(-f_o.item(), (-t_f - f_o).item() + 1, seq_f).astype(int).tolist() - h_sam = np.linspace(h_o.item(), (t_h + h_o).item() - 1, seq_h).astype(int).tolist() - w_sam = np.linspace(w_o.item(), (t_w + w_o).item() - 1, seq_w).astype(int).tolist() - - assert f_o * f >= 0 and h_o * h >= 0 and w_o * w >= 0 - freqs_0 = freqs[0][f_sam] if f_o >= 0 else freqs[0][f_sam].conj() - freqs_0 = freqs_0.view(seq_f, 1, 1, -1) - - freqs_i = torch.cat( - [ - freqs_0.expand(seq_f, seq_h, seq_w, -1), - freqs[1][h_sam].view(1, seq_h, 1, -1).expand(seq_f, seq_h, seq_w, -1), - freqs[2][w_sam].view(1, 1, seq_w, -1).expand(seq_f, seq_h, seq_w, -1), - ], - dim=-1, - ).reshape(seq_len, 1, -1) - elif t_f < 0: - freqs_i = trainable_freqs.unsqueeze(1) - x_i = torch.view_as_complex( - x[i, seq_bucket[-1] : seq_bucket[-1] + seq_len].to(torch.float64).reshape(seq_len, n, -1, 2) - ) - x_i = torch.view_as_real(x_i * freqs_i).flatten(2) - output[i, seq_bucket[-1] : seq_bucket[-1] + seq_len] = x_i - seq_bucket.append(seq_bucket[-1] + seq_len) - return output.to(input_dtype) - - -@torch.compiler.disable -@torch.amp.autocast(current_omni_platform.device_type, enabled=False) -def rope_apply_s2v(x, grid_sizes, freqs, start=None): - """Apply RoPE using precomputed complex freqs (for S2V main transformer). - - Under TP, x has fewer heads than freqs (which is precomputed with all heads). - Since RoPE frequencies are identical across heads, we slice freqs to match. - """ - n = x.size(2) - input_dtype = x.dtype - output = [] - for i, _ in enumerate(x): - s = x.size(1) - x_i = torch.view_as_complex(x[i, :s].to(torch.float64).reshape(s, n, -1, 2)) - freqs_i = freqs[i, :s, :n] - x_i = torch.view_as_real(x_i * freqs_i).flatten(2) - x_i = torch.cat([x_i, x[i, s:]]) - output.append(x_i) - return torch.stack(output).to(input_dtype) - - -@torch.amp.autocast(current_omni_platform.device_type, enabled=False) -def rope_apply_usp(x, grid_sizes, freqs): - """Apply RoPE for Ulysses sequence parallel (context parallel mode).""" - n = x.size(2) - input_dtype = x.dtype - output = [] - for i, _ in enumerate(x): - s = x.size(1) - x_i = torch.view_as_complex(x[i, :s].to(torch.float64).reshape(s, n, -1, 2)) - freqs_i = freqs[i, :, :n] - x_i = torch.view_as_real(x_i * freqs_i).flatten(2) - x_i = torch.cat([x_i, x[i, s:]]) - output.append(x_i) - return torch.stack(output).to(input_dtype) - - def zero_module(module): for p in module.parameters(): p.detach().zero_() @@ -292,6 +129,8 @@ class WanS2VSelfAttention(nn.Module): causal=False, ) + self.rotary_embedding = RotaryEmbeddingWanS2V() + def forward(self, x, seq_lens, grid_sizes, freqs): b, s = x.shape[:2] @@ -311,15 +150,12 @@ class WanS2VSelfAttention(nn.Module): value = value.view(b, s, self.num_kv_heads, self.head_dim) # Apply S2V complex-valued RoPE - query = rope_apply_s2v(query, grid_sizes, freqs) - key = rope_apply_s2v(key, grid_sizes, freqs) + query = self.rotary_embedding(query, freqs) + key = self.rotary_embedding(key, freqs) - # Reshape back after rope (rope_apply_s2v preserves [B, S, N, D]) + # Reshape back after RoPE (preserves [B, S, N, D]) query = query.view(b, s, self.num_heads, self.head_dim) key = key.view(b, s, self.num_kv_heads, self.head_dim) - - # Attention — rope_apply_s2v preserves input dtype (bf16), so - # query/key/value are all bf16 here, matching FlashAttention requirements. hidden_states = self.attn(query, key, value) hidden_states = hidden_states.flatten(2, 3) hidden_states = hidden_states.type_as(x) @@ -411,7 +247,24 @@ class WanS2VTransformerBlock(nn.Module): # 6-way scale-shift modulation self.modulation = nn.Parameter(torch.randn(1, 6, dim) / dim**0.5) - def forward(self, x, e, seq_lens, grid_sizes, freqs, context, context_lens): + def forward( + self, + hidden_states, + encoder_hidden_states=None, + seq_lens=None, + grid_sizes=None, + freqs=None, + context=None, + context_lens=None, + e=None, + ): + x = hidden_states + if encoder_hidden_states is None: + encoder_hidden_states = e + if encoder_hidden_states is None: + raise ValueError("S2V transformer block requires modulation states.") + + e = encoder_hidden_states seg_boundary = e[1] seg_idx = [0, min(max(0, seg_boundary), x.size(1)), x.size(1)] e = e[0] @@ -450,25 +303,68 @@ class WanS2VTransformerBlock(nn.Module): return x -class WanS2VHead(nn.Module): - """S2V output head with modulation.""" +class WanS2VConditionEmbedder(nn.Module): + """Combined time and text condition embeddings for S2V.""" - def __init__(self, dim, out_dim, patch_size, eps=1e-6): + def __init__(self, dim: int, freq_dim: int, text_dim: int, text_len: int): + super().__init__() + self.text_len = text_len + self.timesteps_proj = Timesteps(num_channels=freq_dim, flip_sin_to_cos=True, downscale_freq_shift=0) + self.time_embedder = TimestepEmbedding(in_channels=freq_dim, time_embed_dim=dim) + self.act_fn = nn.SiLU() + self.time_proj = nn.Linear(dim, dim * 6) + self.text_embedder = PixArtAlphaTextProjection(text_dim, dim, act_fn="gelu_tanh") + + def forward( + self, timestep: torch.Tensor, encoder_hidden_states: torch.Tensor, hidden_states_dtype: torch.dtype + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + timestep = self.timesteps_proj(timestep) + temb = self.time_embedder(timestep.to(hidden_states_dtype)) + timestep_proj = self.time_proj(self.act_fn(temb)) + encoder_hidden_states = self.text_embedder( + torch.stack( + [torch.cat([u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) for u in encoder_hidden_states] + ) + ) + return temb, timestep_proj, encoder_hidden_states + + +class WanS2VTimestepProjPrepare(nn.Module): + """Prepares timestep_proj with segment modulation for S2V.""" + + def __init__(self, dim: int): super().__init__() self.dim = dim - self.out_dim = out_dim - self.patch_size = patch_size - out_dim_full = math.prod(patch_size) * out_dim - self.norm = FP32LayerNorm(dim, eps, elementwise_affine=False) - self.head = nn.Linear(dim, out_dim_full) - self.modulation = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + def forward(self, timestep_proj: torch.Tensor, temb: torch.Tensor, zero_timestep: bool, seg_boundary: int): + timestep_proj = timestep_proj.unflatten(1, (6, self.dim)) + if zero_timestep: + temb = temb[:-1] + zero_timestep_proj = timestep_proj[-1:] + timestep_proj = timestep_proj[:-1] + timestep_proj = torch.cat( + [ + timestep_proj.unsqueeze(2), + zero_timestep_proj.unsqueeze(2).repeat(timestep_proj.size(0), 1, 1, 1), + ], + dim=2, + ) + return temb, [timestep_proj, seg_boundary] + else: + timestep_proj = timestep_proj.unsqueeze(2).repeat(1, 1, 2, 1) + return temb, [timestep_proj, 0] - def forward(self, x, e): - e = (self.modulation.to(e.dtype) + e.unsqueeze(1)).chunk(2, dim=1) - x = self.norm(x).type_as(x) * (1 + e[1]) + e[0] - x = self.head(x) - return x + +class WanS2VOutputScaleShiftPrepare(nn.Module): + """Prepares output scale/shift from temb for S2V output modulation.""" + + def __init__(self, dim: int): + super().__init__() + self.scale_shift_table = nn.Parameter(torch.randn(1, 2, dim) / dim**0.5) + + def forward(self, temb: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + shift, scale = (self.scale_shift_table.to(temb.dtype) + temb.unsqueeze(1)).chunk(2, dim=1) + return shift, scale # --------------------------------------------------------------------------- @@ -573,43 +469,10 @@ class CausalAudioEncoder(nn.Module): return res -class AudioCrossAttention(nn.Module): - """Audio cross-attention using nn.Linear (no TP needed).""" +class AudioCrossAttention(WanS2VCrossAttention): + """Audio cross-attention — same as WanS2VCrossAttention (TP-aware).""" - def __init__(self, dim, num_heads, window_size=(-1, -1), qk_norm=True, eps=1e-6): - if dim % num_heads != 0: - raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads})") - super().__init__() - self.dim = dim - self.num_heads = num_heads - self.head_dim = dim // num_heads - - self.q = nn.Linear(dim, dim) - self.k = nn.Linear(dim, dim) - self.v = nn.Linear(dim, dim) - self.o = nn.Linear(dim, dim) - self.norm_q = RMSNorm(dim, eps=eps) if qk_norm else nn.Identity() - self.norm_k = RMSNorm(dim, eps=eps) if qk_norm else nn.Identity() - - self.attn = Attention( - num_heads=num_heads, - head_size=self.head_dim, - num_kv_heads=num_heads, - softmax_scale=1.0 / (self.head_dim**0.5), - causal=False, - ) - - def forward(self, x: torch.Tensor, context: torch.Tensor, context_lens: list[int] | None = None) -> torch.Tensor: - b, n, d = x.size(0), self.num_heads, self.head_dim - - q = self.norm_q(self.q(x)).view(b, -1, n, d) - k = self.norm_k(self.k(context)).view(b, -1, n, d) - v = self.v(context).view(b, -1, n, d) - - x = self.attn(q, k, v) - x = x.flatten(2, 3) - x = self.o(x) - return x + pass class AudioInjector_WAN(nn.Module): @@ -692,8 +555,15 @@ class SimpleSelfAttention(nn.Module): causal=False, ) + self.rotary_embedding = RotaryEmbeddingS2VGrid() + def forward( - self, x: torch.Tensor, seq_lens: list[int], grid_sizes: list[tuple[int, int, int]], freqs: torch.Tensor + self, + x: torch.Tensor, + seq_lens: list[int], + grid_sizes: list[tuple[int, int, int]], + freqs: torch.Tensor, + precomputed_freqs: torch.Tensor | None = None, ) -> torch.Tensor: b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim @@ -701,10 +571,13 @@ class SimpleSelfAttention(nn.Module): k = self.norm_k(self.k(x)).view(b, s, n, d) v = self.v(x).view(b, s, n, d) - q = rope_apply(q, grid_sizes, freqs) - k = rope_apply(k, grid_sizes, freqs) + if precomputed_freqs is not None: + q = RotaryEmbeddingS2VGrid.apply_precomputed(q, precomputed_freqs) + k = RotaryEmbeddingS2VGrid.apply_precomputed(k, precomputed_freqs) + else: + q = self.rotary_embedding(q, grid_sizes, freqs) + k = self.rotary_embedding(k, grid_sizes, freqs) - # rope_apply preserves [B, S, N, D] shape and input dtype q = q.view(b, s, n, d) k = k.view(b, s, n, d) @@ -716,45 +589,54 @@ class SimpleSelfAttention(nn.Module): class SwinSelfAttention(SimpleSelfAttention): def forward( - self, x: torch.Tensor, seq_lens: list[int], grid_sizes: list[tuple[int, int, int]], freqs: torch.Tensor + self, + x: torch.Tensor, + seq_lens: list[int], + grid_sizes: list[tuple[int, int, int]], + freqs: torch.Tensor, + precomputed_freqs: torch.Tensor | None = None, ) -> torch.Tensor: b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim - if b != 1: - raise ValueError( - f"SwinSelfAttention only supports batch_size=1, got batch_size={b}. " - "Batched inference requires refactoring the frame reference and rearrange logic." - ) q = self.norm_q(self.q(x)).view(b, s, n, d) k = self.norm_k(self.k(x)).view(b, s, n, d) v = self.v(x).view(b, s, n, d) - q = rope_apply(q, grid_sizes, freqs) - k = rope_apply(k, grid_sizes, freqs) + if precomputed_freqs is not None: + q = RotaryEmbeddingS2VGrid.apply_precomputed(q, precomputed_freqs) + k = RotaryEmbeddingS2VGrid.apply_precomputed(k, precomputed_freqs) + else: + q = self.rotary_embedding(q, grid_sizes, freqs) + k = self.rotary_embedding(k, grid_sizes, freqs) q = q.view(b, s, n, d) k = k.view(b, s, n, d) T, H, W = grid_sizes[0].tolist() - q = rearrange(q, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) - k = rearrange(k, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) - v = rearrange(v, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) + q = rearrange(q, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) + k = rearrange(k, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) + v = rearrange(v, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) - # Skip ref_q (last frame query) - unused in current implementation - q = q[:-1] + q_frames = q[:, :-1] + ref_k = k[:, -1:].expand(-1, T - 1, -1, -1, -1) + ref_v = v[:, -1:].expand(-1, T - 1, -1, -1, -1) - ref_k = repeat(k[-1:], "1 s n d -> t s n d", t=k.shape[0] - 1) - k = k[:-1] - k = torch.cat([k[:1], k, k[-1:]]) - k = torch.cat([k[1:-1], k[2:], k[:-2], ref_k], dim=1) + k_frames = k[:, :-1] + k_pad = torch.cat([k_frames[:, :1], k_frames, k_frames[:, -1:]], dim=1) + k_frames = torch.cat([k_pad[:, 1:-1], k_pad[:, 2:], k_pad[:, :-2], ref_k], dim=2) - ref_v = repeat(v[-1:], "1 s n d -> t s n d", t=v.shape[0] - 1) - v = v[:-1] - v = torch.cat([v[:1], v, v[-1:]]) - v = torch.cat([v[1:-1], v[2:], v[:-2], ref_v], dim=1) + v_frames = v[:, :-1] + v_pad = torch.cat([v_frames[:, :1], v_frames, v_frames[:, -1:]], dim=1) + v_frames = torch.cat([v_pad[:, 1:-1], v_pad[:, 2:], v_pad[:, :-2], ref_v], dim=2) - out = self.attn(q, k, v) - out = torch.cat([out, ref_v[:1]], axis=0) - out = rearrange(out, "(b t) (h w) n d -> b (t h w) n d", t=T, h=H, w=W) + q_flat = rearrange(q_frames, "b t s n d -> (b t) s n d") + k_flat = rearrange(k_frames, "b t s n d -> (b t) s n d") + v_flat = rearrange(v_frames, "b t s n d -> (b t) s n d") + + out = self.attn(q_flat, k_flat, v_flat) + out = rearrange(out, "(b t) s n d -> b t s n d", b=b) + ref_out = v[:, -1:] + out = torch.cat([out, ref_out], dim=1) + out = rearrange(out, "b t (h w) n d -> b (t h w) n d", h=H, w=W) x = out.flatten(2, 3) x = self.o(x) return x @@ -766,11 +648,6 @@ class CausalSelfAttention(SimpleSelfAttention): ) -> torch.Tensor: shifting = 3 b, s, n, d = *x.shape[:2], self.num_heads, self.head_dim - if b != 1: - raise ValueError( - f"CausalSelfAttention only supports batch_size=1, got batch_size={b}. " - "Batched inference requires refactoring the causal masking and frame reference logic." - ) q = self.norm_q(self.q(x)).view(b, s, n, d) k = self.norm_k(self.k(x)).view(b, s, n, d) @@ -778,55 +655,61 @@ class CausalSelfAttention(SimpleSelfAttention): T, H, W = grid_sizes[0].tolist() - q = rearrange(q, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) - k = rearrange(k, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) - v = rearrange(v, "b (t h w) n d -> (b t) (h w) n d", t=T, h=H, w=W) + q = rearrange(q, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) + k = rearrange(k, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) + v = rearrange(v, "b (t h w) n d -> b t (h w) n d", t=T, h=H, w=W) - # Skip ref_q (last frame query) - unused in current causal implementation - q = q[:-1] + num_frames = T - 1 - grid_sizes_q = torch.tensor([[1, H, W]] * q.shape[0], dtype=torch.long) - start = [[shifting, 0, 0]] * q.shape[0] - q = rope_apply(q, grid_sizes_q, freqs, start=start) - q = q.view(q.shape[0], -1, n, d) + # Query: skip ref frame, apply RoPE per frame + q_frames = rearrange(q[:, :-1], "b t s n d -> (b t) s n d") + grid_sizes_q = torch.tensor([[1, H, W]] * (b * num_frames), dtype=torch.long) + start = [[shifting, 0, 0]] * (b * num_frames) + q_frames = self.rotary_embedding(q_frames, grid_sizes_q, freqs, start=start) + q_frames = q_frames.view(b * num_frames, -1, n, d) - ref_k = k[-1:] - grid_sizes_ref = torch.tensor([[1, H, W]], dtype=torch.long) - start_ref = [[shifting + 10, 0, 0]] - ref_k = rope_apply(ref_k, grid_sizes_ref, freqs, start_ref) - ref_k = ref_k.view(1, -1, n, d) - ref_k = repeat(ref_k, "1 s n d -> t s n d", t=k.shape[0] - 1) + # Ref key: one per batch, RoPE with shifted start, expand to all frames + ref_k = rearrange(k[:, -1:], "b 1 s n d -> b s n d") + grid_sizes_ref = torch.tensor([[1, H, W]] * b, dtype=torch.long) + start_ref = [[shifting + 10, 0, 0]] * b + ref_k = self.rotary_embedding(ref_k, grid_sizes_ref, freqs, start_ref) + ref_k = ref_k.view(b, 1, -1, n, d).expand(-1, num_frames, -1, -1, -1) + ref_k = rearrange(ref_k, "b t s n d -> (b t) s n d") - k = k[:-1] - k = torch.cat([*([k[:1]] * shifting), k]) + # Causal key: pad with shifting duplicates, gather shifting+1 neighboring frames + k_frames = k[:, :-1] + k_pad = torch.cat([k_frames[:, :1].expand(-1, shifting, -1, -1, -1), k_frames], dim=1) cat_k = [] for i in range(shifting): - cat_k.append(k[i : i - shifting]) - cat_k.append(k[shifting:]) - k = torch.cat(cat_k, dim=1) + cat_k.append(k_pad[:, i : i + num_frames]) + cat_k.append(k_pad[:, shifting:]) + k_causal = torch.cat(cat_k, dim=2) + k_causal = rearrange(k_causal, "b t s n d -> (b t) s n d") - grid_sizes_k = torch.tensor([[shifting + 1, H, W]] * (q.shape[0]), dtype=torch.long) - k = rope_apply(k, grid_sizes_k, freqs) - k = k.view(k.shape[0], -1, n, d) - k = torch.cat([k, ref_k], dim=1) + grid_sizes_k = torch.tensor([[shifting + 1, H, W]] * (b * num_frames), dtype=torch.long) + k_causal = self.rotary_embedding(k_causal, grid_sizes_k, freqs) + k_causal = k_causal.view(b * num_frames, -1, n, d) + k_causal = torch.cat([k_causal, ref_k], dim=1) - ref_v = repeat(v[-1:], "1 s n d -> t s n d", t=q.shape[0]) - v = v[:-1] - v = torch.cat([*([v[:1]] * shifting), v]) + # Causal value: same padding structure as key, plus ref + ref_v = v[:, -1:].expand(-1, num_frames, -1, -1, -1) + ref_v_flat = rearrange(ref_v, "b t s n d -> (b t) s n d") + v_frames = v[:, :-1] + v_pad = torch.cat([v_frames[:, :1].expand(-1, shifting, -1, -1, -1), v_frames], dim=1) cat_v = [] for i in range(shifting): - cat_v.append(v[i : i - shifting]) - cat_v.append(v[shifting:]) - v = torch.cat(cat_v, dim=1) - v = torch.cat([v, ref_v], dim=1) + cat_v.append(v_pad[:, i : i + num_frames]) + cat_v.append(v_pad[:, shifting:]) + v_causal = torch.cat(cat_v, dim=2) + v_causal = rearrange(v_causal, "b t s n d -> (b t) s n d") + v_causal = torch.cat([v_causal, ref_v_flat], dim=1) - outs = [] - for i in range(q.shape[0]): - out = self.attn(q[i : i + 1], k[i : i + 1], v[i : i + 1]) - outs.append(out) - out = torch.cat(outs, dim=0) - out = torch.cat([out, ref_v[:1]], axis=0) - out = rearrange(out, "(b t) (h w) n d -> b (t h w) n d", t=T, h=H, w=W) + # Batched attention (all frames at once) + out = self.attn(q_frames, k_causal, v_causal) + out = rearrange(out, "(b t) s n d -> b t s n d", b=b) + ref_out = v[:, -1:] + out = torch.cat([out, ref_out], dim=1) + out = rearrange(out, "b t (h w) n d -> b (t h w) n d", h=H, w=W) x = out.flatten(2, 3) x = self.o(x) return x @@ -861,9 +744,14 @@ class MotionerAttentionBlock(nn.Module): self.ffn = nn.Sequential(nn.Linear(dim, ffn_dim), nn.GELU(approximate="tanh"), nn.Linear(ffn_dim, dim)) def forward( - self, x: torch.Tensor, seq_lens: list[int], grid_sizes: list[tuple[int, int, int]], freqs: torch.Tensor + self, + x: torch.Tensor, + seq_lens: list[int], + grid_sizes: list[tuple[int, int, int]], + freqs: torch.Tensor, + precomputed_freqs: torch.Tensor | None = None, ) -> torch.Tensor: - y = self.self_attn(self.norm1(x).type_as(x), seq_lens, grid_sizes, freqs) + y = self.self_attn(self.norm1(x).type_as(x), seq_lens, grid_sizes, freqs, precomputed_freqs=precomputed_freqs) x = x + y y = self.ffn(self.norm2(x).type_as(x)) x = x + y @@ -934,11 +822,8 @@ class MotionerTransformers(nn.Module): raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads})") if (dim // num_heads) % 2 != 0: raise ValueError(f"dim // num_heads ({dim // num_heads}) must be even") - d = dim // num_heads - self.freqs = torch.cat( - [rope_params(1024, d - 4 * (d // 6)), rope_params(1024, 2 * (d // 6)), rope_params(1024, 2 * (d // 6))], - dim=1, - ) + self.rope = WanS2VRotaryPosEmbed(num_heads, dim // num_heads) + self.freqs = self.rope.freqs self.gradient_checkpointing = False self.motion_side_len = int(math.sqrt(motion_token_num)) @@ -948,6 +833,7 @@ class MotionerTransformers(nn.Module): self.trainable_token_pos_emb = trainable_token_pos_emb if trainable_token_pos_emb: + d = dim // num_heads x = torch.zeros([1, motion_token_num, num_heads, d]) x[..., ::2] = 1 gride_sizes = [ @@ -957,13 +843,13 @@ class MotionerTransformers(nn.Module): torch.tensor([1, self.motion_side_len, self.motion_side_len]).unsqueeze(0).repeat(1, 1), ] ] - token_freqs = rope_apply(x, gride_sizes, self.freqs) + token_freqs = RotaryEmbeddingS2VGrid.forward(x, gride_sizes, self.rope.freqs) token_freqs = token_freqs[0, :, 0].reshape(motion_token_num, -1, 2) token_freqs = token_freqs * 0.01 self.token_freqs = torch.nn.Parameter(token_freqs) def forward(self, x: torch.Tensor) -> torch.Tensor: - # x[0].shape[1] contains motion frames count, but not used directly here + # x: [B, C, T, H, W] tensor device = self.patch_embedding.weight.device freqs = self.freqs if freqs.device != device: @@ -977,18 +863,20 @@ class MotionerTransformers(nn.Module): if self.enable_tsm: sample_idx = [ - _sample_indices(u.shape[1], stride=self.motion_stride, expand_ratio=self.expand_ratio, c=self.sample_c) - for u in x + _sample_indices( + x[i].shape[1], stride=self.motion_stride, expand_ratio=self.expand_ratio, c=self.sample_c + ) + for i in range(x.shape[0]) ] - x = [torch.flip(torch.flip(u, [1])[:, idx], [1]) for idx, u in zip(sample_idx, x)] + x = torch.stack([torch.flip(torch.flip(x[i], [1])[:, idx], [1]) for i, idx in enumerate(sample_idx)]) - x = [self.patch_embedding(u.unsqueeze(0)) for u in x] + x = self.patch_embedding(x) - seq_f, seq_h, seq_w = x[0].shape[-3:] - batch_size = len(x) + batch_size = x.shape[0] + seq_f, seq_h, seq_w = x.shape[-3:] if not self.enable_tsm: - grid_sizes = torch.stack([torch.tensor(u.shape[2:], dtype=torch.long) for u in x]) - grid_sizes = [[torch.zeros_like(grid_sizes), grid_sizes, grid_sizes]] + grid_sizes_t = torch.tensor([list(x.shape[2:])], dtype=torch.long).repeat(batch_size, 1) + grid_sizes = [[torch.zeros_like(grid_sizes_t), grid_sizes_t, grid_sizes_t]] seq_f = 0 else: grid_sizes = [] @@ -1003,10 +891,9 @@ class MotionerTransformers(nn.Module): grid_sizes += tsm_frame_grid_sizes seq_f = sample_idx[0][-1] + 1 - x = [u.flatten(2).transpose(1, 2) for u in x] - seq_lens = torch.tensor([u.size(1) for u in x], dtype=torch.long) - x = torch.cat([u for u in x]) - batch_size = len(x) + x = x.flatten(2).transpose(1, 2) + seq_lens = torch.full((batch_size,), x.shape[1], dtype=torch.long) + batch_size = x.shape[0] token_grid_sizes = [ [ @@ -1022,11 +909,15 @@ class MotionerTransformers(nn.Module): grid_sizes = grid_sizes + token_grid_sizes token_len = self.token.shape[1] - token = self.token.clone().repeat(x.shape[0], 1, 1).contiguous() - seq_lens = seq_lens + torch.tensor([t.size(0) for t in token], dtype=torch.long) + token = self.token.clone().repeat(batch_size, 1, 1).contiguous() + seq_lens = seq_lens + token_len x = torch.cat([x, token], dim=1) - kwargs = dict(seq_lens=seq_lens, grid_sizes=grid_sizes, freqs=freqs) + precomputed_freqs = RotaryEmbeddingS2VGrid.precompute( + x.shape[1], self.num_heads, self.dim // self.num_heads, grid_sizes, freqs, x.device + ) + + kwargs = dict(seq_lens=seq_lens, grid_sizes=grid_sizes, freqs=freqs, precomputed_freqs=precomputed_freqs) for block in self.blocks: x = block(x, **kwargs) @@ -1062,11 +953,8 @@ class FramePackMotioner(nn.Module): raise ValueError(f"inner_dim ({inner_dim}) must be divisible by num_heads ({num_heads})") if (inner_dim // num_heads) % 2 != 0: raise ValueError(f"inner_dim // num_heads ({inner_dim // num_heads}) must be even") - d = inner_dim // num_heads - self.freqs = torch.cat( - [rope_params(1024, d - 4 * (d // 6)), rope_params(1024, 2 * (d // 6)), rope_params(1024, 2 * (d // 6))], - dim=1, - ) + self.rope = WanS2VRotaryPosEmbed(num_heads, inner_dim // num_heads) + self.freqs = self.rope.freqs self.drop_mode = drop_mode def forward(self, motion_latents: torch.Tensor, add_last_motion: int = 2) -> torch.Tensor: @@ -1147,12 +1035,7 @@ class FramePackMotioner(nn.Module): grid_sizes = grid_sizes + grid_sizes_2x + grid_sizes_4x - motion_rope_emb = rope_precompute( - motion_lat.detach().view(1, motion_lat.shape[1], self.num_heads, self.inner_dim // self.num_heads), - grid_sizes, - self.freqs, - start=None, - ) + motion_rope_emb = self.rope(motion_lat.detach(), grid_sizes) mot.append(motion_lat) mot_remb.append(motion_rope_emb) @@ -1264,9 +1147,7 @@ class WanS2VTransformer3DModel(nn.Module): # Embeddings self.patch_embedding = nn.Conv3d(in_dim, dim, kernel_size=patch_size, stride=patch_size) - self.text_embedding = nn.Sequential(nn.Linear(text_dim, dim), nn.GELU(approximate="tanh"), nn.Linear(dim, dim)) - self.time_embedding = nn.Sequential(nn.Linear(freq_dim, dim), nn.SiLU(), nn.Linear(dim, dim)) - self.time_projection = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 6)) + self.condition_embedder = WanS2VConditionEmbedder(dim, freq_dim, text_dim, text_len) # Transformer blocks (TP-enabled) self.blocks = nn.ModuleList( @@ -1277,18 +1158,18 @@ class WanS2VTransformer3DModel(nn.Module): ) # Output head - self.head = WanS2VHead(dim, out_dim, patch_size, eps) + self.norm_out = FP32LayerNorm(dim, eps, elementwise_affine=False) + self.proj_out = nn.Linear(dim, math.prod(patch_size) * out_dim) + self.output_scale_shift_prepare = WanS2VOutputScaleShiftPrepare(dim) + self.timestep_proj_prepare = WanS2VTimestepProjPrepare(dim) # RoPE base frequencies (complex-valued) if dim % num_heads != 0: raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads})") if (dim // num_heads) % 2 != 0: raise ValueError(f"dim // num_heads ({dim // num_heads}) must be even") - d = dim // num_heads - self.freqs = torch.cat( - [rope_params(1024, d - 4 * (d // 6)), rope_params(1024, 2 * (d // 6)), rope_params(1024, 2 * (d // 6))], - dim=1, - ) + self.rope = WanS2VRotaryPosEmbed(num_heads, dim // num_heads) + self.freqs = self.rope.freqs self.use_context_parallel = False @@ -1323,6 +1204,8 @@ class WanS2VTransformer3DModel(nn.Module): self.zero_timestep = zero_timestep self.add_last_motion = add_last_motion + self._cached_rope_key = None + self._cached_rope_emb = None # Motion modules if enable_motioner and enable_framepack: @@ -1369,7 +1252,7 @@ class WanS2VTransformer3DModel(nn.Module): .repeat(1, 1), ] ] - token_freqs = rope_apply(x, gride_sizes, self.freqs) + token_freqs = RotaryEmbeddingS2VGrid.forward(x, gride_sizes, self.rope.freqs) token_freqs = token_freqs[0, :, 0].reshape(motion_token_num, -1, 2) token_freqs = token_freqs * 0.01 self.token_freqs = nn.Parameter(token_freqs) @@ -1386,12 +1269,26 @@ class WanS2VTransformer3DModel(nn.Module): if hasattr(self, "cond_encoder"): self.cond_encoder = zero_module(self.cond_encoder) for i in range(len(self.audio_injector.injector)): - self.audio_injector.injector[i].o = zero_module(self.audio_injector.injector[i].o) + self.audio_injector.injector[i].to_out = zero_module(self.audio_injector.injector[i].to_out) if self.enable_adain: self.audio_injector.injector_adain_layers[i].linear = zero_module( self.audio_injector.injector_adain_layers[i].linear ) + # ------------------------------------------------------------------ + # RoPE preparation + # ------------------------------------------------------------------ + + def prepare_rope(self, x, grid_sizes): + """Precompute RoPE for the concatenated hidden states (cached across steps).""" + cache_key = (x.shape[0], x.shape[1], x.device) + if self._cached_rope_key == cache_key and self._cached_rope_emb is not None: + return self._cached_rope_emb + result = self.rope(x.detach(), grid_sizes) + self._cached_rope_key = cache_key + self._cached_rope_emb = result + return result + # ------------------------------------------------------------------ # Motion processing # ------------------------------------------------------------------ @@ -1414,12 +1311,7 @@ class WanS2VTransformer3DModel(nn.Module): torch.tensor([self.lat_motion_frames, height, width]).unsqueeze(0).repeat(1, 1), ] ] - motion_rope_emb = rope_precompute( - flat_mot.detach().view(1, flat_mot.shape[1], self.num_heads, self.dim // self.num_heads), - motion_grid_sizes, - self.freqs, - start=None, - ) + motion_rope_emb = self.rope(flat_mot.detach(), motion_grid_sizes) mot_remb.append(motion_rope_emb) flatten_mot.append(flat_mot) return flatten_mot, mot_remb @@ -1432,25 +1324,21 @@ class WanS2VTransformer3DModel(nn.Module): return flatten_mot, mot_remb def process_motion_transformer_motioner(self, motion_latents, drop_motion_frames=False, add_last_motion=True): - batch_size = len(motion_latents) - height = motion_latents[0].shape[2] // self.patch_size[1] - width = motion_latents[0].shape[3] // self.patch_size[2] + batch_size = motion_latents.shape[0] + height = motion_latents.shape[3] // self.patch_size[1] + width = motion_latents.shape[4] // self.patch_size[2] - freqs = self.freqs - device = self.patch_embedding.weight.device - if freqs.device != device: - freqs = freqs.to(device) + trainable_f = None if self.trainable_token_pos_emb: with torch.amp.autocast(current_omni_platform.device_type, dtype=torch.float64): token_freqs = self.token_freqs.to(torch.float64) token_freqs = token_freqs / token_freqs.norm(dim=-1, keepdim=True) - freqs = [freqs, torch.view_as_complex(token_freqs)] + trainable_f = torch.view_as_complex(token_freqs) if not drop_motion_frames and add_last_motion: - last_motion_latent = [u[:, -1:] for u in motion_latents] - last_mot = [self.patch_embedding(m.unsqueeze(0)) for m in last_motion_latent] - last_mot = [m.flatten(2).transpose(1, 2) for m in last_mot] - last_mot = torch.cat(last_mot) + last_motion_latent = motion_latents[:, :, -1:] + last_mot = self.patch_embedding(last_motion_latent) + last_mot = last_mot.flatten(2).transpose(1, 2) gride_sizes = [ [ torch.tensor([-1, 0, 0]).unsqueeze(0).repeat(batch_size, 1), @@ -1459,9 +1347,7 @@ class WanS2VTransformer3DModel(nn.Module): ] ] else: - last_mot = torch.zeros( - [batch_size, 0, self.dim], device=motion_latents[0].device, dtype=motion_latents[0].dtype - ) + last_mot = torch.zeros([batch_size, 0, self.dim], device=motion_latents.device, dtype=motion_latents.dtype) gride_sizes = [] zip_motion = self.motioner(motion_latents) @@ -1483,12 +1369,7 @@ class WanS2VTransformer3DModel(nn.Module): mot = torch.cat([last_mot, zip_motion], dim=1) gride_sizes = gride_sizes + zip_motion_grid_sizes - motion_rope_emb = rope_precompute( - mot.detach().view(batch_size, mot.shape[1], self.num_heads, self.dim // self.num_heads), - gride_sizes, - freqs, - start=None, - ) + motion_rope_emb = self.rope(mot.detach(), gride_sizes, trainable_freqs=trainable_f) return [m.unsqueeze(0) for m in mot], [r.unsqueeze(0) for r in motion_rope_emb] def inject_motion( @@ -1521,12 +1402,11 @@ class WanS2VTransformer3DModel(nn.Module): audio_emb = self.merged_audio_emb num_frames = audio_emb.shape[1] - input_hidden_states = hidden_states[:, : self.original_seq_len].clone() + input_hidden_states = hidden_states[:, : self.original_seq_len] input_hidden_states = rearrange(input_hidden_states, "b (t n) c -> (b t) n c", t=num_frames) if self.enable_adain and self.adain_mode == "attn_norm": - audio_emb_global = self.audio_emb_global - audio_emb_global = rearrange(audio_emb_global, "b t n c -> (b t) n c") + audio_emb_global = rearrange(self.audio_emb_global, "b t n c -> (b t) n c") adain_hidden_states = self.audio_injector.injector_adain_layers[audio_attn_id]( input_hidden_states, temb=audio_emb_global[:, 0] ) @@ -1534,8 +1414,7 @@ class WanS2VTransformer3DModel(nn.Module): else: attn_hidden_states = self.audio_injector.injector_pre_norm_feat[audio_attn_id](input_hidden_states) - audio_emb = rearrange(audio_emb, "b t n c -> (b t) n c", t=num_frames) - attn_audio_emb = audio_emb + attn_audio_emb = rearrange(audio_emb, "b t n c -> (b t) n c") residual_out = self.audio_injector.injector[audio_attn_id]( x=attn_hidden_states, context=attn_audio_emb, @@ -1543,7 +1422,7 @@ class WanS2VTransformer3DModel(nn.Module): * attn_audio_emb.shape[1], ) residual_out = rearrange(residual_out, "(b t) n c -> b (t n) c", t=num_frames) - hidden_states[:, : self.original_seq_len] = hidden_states[:, : self.original_seq_len] + residual_out + hidden_states[:, : self.original_seq_len].add_(residual_out) return hidden_states @@ -1570,7 +1449,7 @@ class WanS2VTransformer3DModel(nn.Module): result = {} if self.enable_adain: audio_emb_global, audio_emb = audio_emb_res - result["audio_emb_global"] = audio_emb_global[:, motion_frames[1] :].clone() + result["audio_emb_global"] = audio_emb_global[:, motion_frames[1] :] else: audio_emb = audio_emb_res result["audio_emb"] = audio_emb[:, motion_frames[1] :, :] @@ -1578,60 +1457,51 @@ class WanS2VTransformer3DModel(nn.Module): def forward( self, - x: torch.Tensor, - t: torch.Tensor, - context: torch.Tensor, - seq_len: int, + hidden_states: torch.Tensor, + timestep: torch.Tensor, + encoder_hidden_states: torch.Tensor, ref_latents: torch.Tensor, motion_latents: torch.Tensor, cond_states: torch.Tensor, - audio_input: torch.Tensor | None = None, - motion_frames: list[int] = [17, 5], + encoder_hidden_states_audio: dict[str, torch.Tensor] | None = None, add_last_motion: int = 2, drop_motion_frames: bool = False, - audio_emb: dict[str, torch.Tensor] | None = None, - *extra_args: object, + return_dict: bool = True, **extra_kwargs: object, ) -> torch.Tensor: """Forward pass for S2V transformer. Args: - x: Noisy latents [B, C, T, H, W] - t: Timesteps [B] - context: Text embeddings [B, seq_len, dim] - seq_len: Sequence length + hidden_states: Noisy latents [B, C, T, H, W] + timestep: Timesteps [B] + encoder_hidden_states: Text embeddings [B, seq_len, dim] ref_latents: Reference image latents [B, C, 1, H, W] motion_latents: Motion context latents [B, C, T_motion, H, W] - cond_states: Pose condition latents [B, C, T, H, W] — typically zero tensor - for no pose control. Iterates over batch dimension (dim 0). - audio_input: Raw audio features (optional if audio_emb provided) - motion_frames: [num_frames, num_latent_frames] for audio alignment + cond_states: Pose condition latents [B, C, T, H, W] + encoder_hidden_states_audio: Precomputed audio embeddings from encode_audio() add_last_motion: Number of last motion frames to repeat drop_motion_frames: Whether to drop first motion frame - audio_emb: Precomputed audio embeddings from encode_audio() + return_dict: Whether to return Transformer2DModelOutput Returns: Predicted noise [B, C, T, H, W] """ + # Clear cached state from previous forward (prevents stale data from + # dummy warmup or prior clips polluting this inference) + self._cached_rope_key = None + self._cached_rope_emb = None + + x = hidden_states + t = timestep + context = encoder_hidden_states + audio_emb = encoder_hidden_states_audio add_last_motion = self.add_last_motion * add_last_motion - # Audio encoding — use precomputed embeddings if available - if audio_emb is not None: - self.merged_audio_emb = audio_emb["audio_emb"] - if self.enable_adain: - self.audio_emb_global = audio_emb["audio_emb_global"] - else: - audio_input = torch.cat( - [audio_input[..., 0:1].repeat(1, 1, 1, motion_frames[0]), audio_input], - dim=-1, - ) - audio_emb_res = self.casual_audio_encoder(audio_input) - if self.enable_adain: - audio_emb_global, audio_emb_local = audio_emb_res - self.audio_emb_global = audio_emb_global[:, motion_frames[1] :].clone() - else: - audio_emb_local = audio_emb_res - self.merged_audio_emb = audio_emb_local[:, motion_frames[1] :, :] + # Audio embeddings — must be precomputed by pipeline via encode_audio() + assert audio_emb is not None, "S2V requires precomputed audio_emb (encoder_hidden_states_audio)" + self.merged_audio_emb = audio_emb["audio_emb"] + if self.enable_adain: + self.audio_emb_global = audio_emb["audio_emb_global"] # Patch embedding + condition # NOTE: x and cond_states are 5D tensors [B, C, T, H, W]. Iterating over them @@ -1674,8 +1544,7 @@ class WanS2VTransformer3DModel(nn.Module): # Precompute RoPE x = torch.cat(x) - b, s, n, d = x.size(0), x.size(1), self.num_heads, self.dim // self.num_heads - self.pre_compute_freqs = rope_precompute(x.detach().view(b, s, n, d), grid_sizes, self.freqs, start=None) + self.pre_compute_freqs = self.prepare_rope(x, grid_sizes) x = [u.unsqueeze(0) for u in x] self.pre_compute_freqs = [u.unsqueeze(0) for u in self.pre_compute_freqs] @@ -1697,29 +1566,13 @@ class WanS2VTransformer3DModel(nn.Module): x = x + self.trainable_cond_mask(mask_input).to(x.dtype) - # Time embeddings — sinusoidal_embedding_1d uses float64 internally - # for precision, then we cast to the model dtype (bf16) since the - # downstream scale/shift modulation doesn't need float32. + # Condition embeddings (time + text) if self.zero_timestep: t = torch.cat([t, torch.zeros([1], dtype=t.dtype, device=t.device)]) - e = self.time_embedding(sinusoidal_embedding_1d(self.freq_dim, t).to(x.dtype)) - e0 = self.time_projection(e).unflatten(1, (6, self.dim)) + e, timestep_proj, context = self.condition_embedder(t, context, x.dtype) + e, e0 = self.timestep_proj_prepare(timestep_proj, e, self.zero_timestep, self.original_seq_len) - if self.zero_timestep: - e = e[:-1] - zero_e0 = e0[-1:] - e0 = e0[:-1] - e0 = torch.cat([e0.unsqueeze(2), zero_e0.unsqueeze(2).repeat(e0.size(0), 1, 1, 1)], dim=2) - e0 = [e0, self.original_seq_len] - else: - e0 = e0.unsqueeze(2).repeat(1, 1, 2, 1) - e0 = [e0, 0] - - # Context embedding context_lens = None - context = self.text_embedding( - torch.stack([torch.cat([u, u.new_zeros(self.text_len - u.size(0), u.size(1))]) for u in context]) - ) # Transformer blocks kwargs = dict( @@ -1735,11 +1588,19 @@ class WanS2VTransformer3DModel(nn.Module): x = block(x, **kwargs) x = self.after_transformer_block(idx, x) - # Output + # Output norm, projection & unpatchify x = x[:, : self.original_seq_len] - x = self.head(x, e) + shift, scale = self.output_scale_shift_prepare(e) + x = self.norm_out(x).type_as(x) * (1 + scale) + shift + x = self.proj_out(x) x = self.unpatchify(x, original_grid_sizes) - return [u.float() for u in x] + output = torch.stack([u.float() for u in x]) + + if not return_dict: + return (output,) + from diffusers.models.modeling_outputs import Transformer2DModelOutput + + return Transformer2DModelOutput(sample=output) def unpatchify(self, x, grid_sizes): c = self.out_dim @@ -1802,26 +1663,44 @@ class WanS2VTransformer3DModel(nn.Module): name = name.replace(".ffn.0.", ".ffn.net_0.proj.") name = name.replace(".ffn.2.", ".ffn.net_2.") + # Audio injector weight remapping (q/k/v/o → to_q/to_k/to_v/to_out) + if "audio_injector.injector." in name: + name = name.replace(".q.", ".to_q.") + name = name.replace(".k.", ".to_k.") + name = name.replace(".v.", ".to_v.") + name = name.replace(".o.", ".to_out.") + + # Output head remapping (head.* → norm_out/proj_out/output_scale_shift_prepare) + if name.startswith("head.head."): + name = "proj_out." + name[len("head.head.") :] + elif name == "head.modulation": + name = "output_scale_shift_prepare.scale_shift_table" + + # Condition embedder remapping (only top-level) + # time_embedding.0.* → condition_embedder.time_embedder.linear_1.* + # time_embedding.2.* → condition_embedder.time_embedder.linear_2.* + if name.startswith("time_embedding.0."): + name = "condition_embedder.time_embedder.linear_1." + name[len("time_embedding.0.") :] + elif name.startswith("time_embedding.2."): + name = "condition_embedder.time_embedder.linear_2." + name[len("time_embedding.2.") :] + # time_projection.1.* → condition_embedder.time_proj.* + elif name.startswith("time_projection.1."): + name = "condition_embedder.time_proj." + name[len("time_projection.1.") :] + # text_embedding.0.* → condition_embedder.text_embedder.linear_1.* + # text_embedding.2.* → condition_embedder.text_embedder.linear_2.* + elif name.startswith("text_embedding.0."): + name = "condition_embedder.text_embedder.linear_1." + name[len("text_embedding.0.") :] + elif name.startswith("text_embedding.2."): + name = "condition_embedder.text_embedder.linear_2." + name[len("text_embedding.2.") :] + if name not in params_dict: logger.warning(f"Skipping weight {original_name} -> {name}") continue param = params_dict[name] - # TP-shard norm weights for main blocks - if ( - tp_size > 1 - and is_main_block - and any( - norm_name in name - for norm_name in [ - ".self_attn.norm_q.", - ".self_attn.norm_k.", - ".cross_attn.norm_q.", - ".cross_attn.norm_k.", - ] - ) - ): + # TP-shard norm weights for TP-aware attention modules + if tp_size > 1 and (".norm_q." in name or ".norm_k." in name) and loaded_weight.shape[0] != param.shape[0]: shard_size = loaded_weight.shape[0] // tp_size loaded_weight = loaded_weight[tp_rank * shard_size : (tp_rank + 1) * shard_size] From 6e8ef213d896c36e5a27992f21e773d68b45a795 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:25:01 +0800 Subject: [PATCH 03/75] [BugFix] Support ModelOpt FP8 under batched diffusion serving (#4155) Signed-off-by: lishunyang12 Co-authored-by: lishunyang12 --- vllm_omni/patch.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py index e5dc955eb..29cc56e0f 100644 --- a/vllm_omni/patch.py +++ b/vllm_omni/patch.py @@ -188,3 +188,32 @@ def _patch_chat_template_registry(): _patch_chat_template_registry() + + +def _patch_scaled_mm_fp8_contiguous_activation(): + """Support batched diffusion activations on the ModelOpt FP8 (ScaledMM) path. + + The FP8 ScaledMM linear flattens its activation with ``x.view(-1, ...)``, which + needs a contiguous tensor. Under step-execution batching (``--max-num-seqs > 1``) + the sequence-packed diffusion activations can be non-contiguous, so we make the + activation contiguous before the GEMM (no-op when it already is). Mixed FP8/NVFP4 + routes through the CUTLASS NVFP4 path and is unaffected. + """ + try: + from vllm.model_executor.kernels.linear.scaled_mm.ScaledMMLinearKernel import ( + ScaledMMLinearKernel, + ) + except ImportError: + return + + _original_apply_weights = ScaledMMLinearKernel.apply_weights + + def _patched_apply_weights(self, layer, x, bias=None): + if not x.is_contiguous(): + x = x.contiguous() + return _original_apply_weights(self, layer, x, bias) + + ScaledMMLinearKernel.apply_weights = _patched_apply_weights + + +_patch_scaled_mm_fp8_contiguous_activation() From 95d56cf0626099b09733a16464dca2a80d7e2c0d Mon Sep 17 00:00:00 2001 From: Yuanheng Zhao <54058983+yuanheng-zhao@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:27:42 +0800 Subject: [PATCH 04/75] [Bugfix] Fix Compatibility of Ming-flash-omni-2.0 on transformers 5.X and vllm 0.22 (#4080) Signed-off-by: Yuanheng Zhao Signed-off-by: akshatvishu Co-authored-by: akshatvishu --- recipes/inclusionAI/Ming-flash-omni-2.0.md | 15 ++-- .../ming_flash_omni_thinker.py | 17 ++--- .../modeling_bailing_moe_v2.py | 8 ++- .../models/ming_flash_omni/talker_module.py | 3 +- .../configs/ming_flash_omni.py | 12 +++- .../transformers_utils/processors/ming.py | 68 +++++++++++++++++-- 6 files changed, 97 insertions(+), 26 deletions(-) diff --git a/recipes/inclusionAI/Ming-flash-omni-2.0.md b/recipes/inclusionAI/Ming-flash-omni-2.0.md index 3dcda2cbe..4a3a86669 100644 --- a/recipes/inclusionAI/Ming-flash-omni-2.0.md +++ b/recipes/inclusionAI/Ming-flash-omni-2.0.md @@ -57,12 +57,6 @@ Adjust `devices` in the YAML to match your hardware. #### Command -Thinker only (text output): - -```bash -vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni --port 8091 -``` - Thinker + talker (text and/or audio output): ```bash @@ -72,6 +66,15 @@ vllm serve Jonathan1909/Ming-flash-omni-2.0 \ --log-stats ``` +Thinker only (text-only output): + +```bash +vllm serve Jonathan1909/Ming-flash-omni-2.0 \ + --omni \ + --deploy-config vllm_omni/deploy/ming_flash_omni_thinker_only.yaml \ + --port 8091 +``` + `--log-stats` is optional but recommended while validating the deployment. #### Verification 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 0a9ce0f5c..e0a49935d 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 @@ -72,6 +72,7 @@ from vllm_omni.transformers_utils.processors.ming import ( PLACEHOLDER_VIDEO_TOKEN_IN_TEXT, MingFlashOmniProcessor, MingWhisperFeatureExtractor, + raise_missing_video_processor, ) from .audio_encoder import WhisperAudioEncoder @@ -540,20 +541,20 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO if images is not None: image_outputs = hf_processor.image_processor( images=images, - videos=None, return_tensors="pt", ) data.update(image_outputs) videos = mm_data.get("videos", None) if videos is not None: - # TODO: ``videos=`` on image_processor is deprecated since - # transformers v4.57 (removed in v5); migrate to Qwen2VLVideoProcessor. - video_outputs = hf_processor.image_processor( - images=None, - videos=videos, - return_tensors="pt", - ) + video_processor = getattr(hf_processor, "video_processor", None) + if video_processor is not None: + video_outputs = video_processor( + videos=videos, + return_tensors="pt", + ) + else: + raise_missing_video_processor() # Rename keys to distinguish from images if "pixel_values" in video_outputs: video_outputs["pixel_values_videos"] = video_outputs.pop("pixel_values") diff --git a/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py b/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py index ca7d00f50..5a1af2fc2 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/modeling_bailing_moe_v2.py @@ -714,6 +714,9 @@ class BailingMoeV2Model(nn.Module): def get_input_embeddings(self): return self.word_embeddings + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor: + return self.word_embeddings(input_ids) + def forward( self, input_ids: torch.Tensor, @@ -794,6 +797,9 @@ class BailingMoeV2ForCausalLM(nn.Module, CustomProcessMixin): self.sampler = Sampler() self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor: + return self.model.embed_input_ids(input_ids) + def forward( self, input_ids: torch.Tensor, @@ -818,7 +824,7 @@ class BailingMoeV2ForCausalLM(nn.Module, CustomProcessMixin): hidden_states: torch.Tensor, sampling_metadata, ) -> torch.Tensor | None: - logits = self.logits_processor(self.lm_head, hidden_states, sampling_metadata) + logits = self.logits_processor(self.lm_head, hidden_states) return logits def sample( diff --git a/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py b/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py index 6404b3fb8..6b2c6b2f2 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py @@ -902,6 +902,7 @@ class MingAudioGenerator: if self._audio_vae is None: return requested_max_steps + # Transformers >=5.x may expose these config values as 0-d tensors. sample_rate = float(self._audio_vae.config.sample_rate) vae_patch_size = float(getattr(self._audio_vae.config, "patch_size", 4)) hop_size = float(getattr(self._audio_vae.decoder, "hop_length", 320)) @@ -1041,7 +1042,7 @@ class MingAudioGenerator: use_cache=True, ) else: - past_seen_tokens = past_key_values.get_seq_length() + past_seen_tokens = int(past_key_values.get_seq_length()) cache_position = torch.arange( past_seen_tokens, past_seen_tokens + inputs_embeds.shape[1], diff --git a/vllm_omni/transformers_utils/configs/ming_flash_omni.py b/vllm_omni/transformers_utils/configs/ming_flash_omni.py index 376d902e3..6799a411e 100644 --- a/vllm_omni/transformers_utils/configs/ming_flash_omni.py +++ b/vllm_omni/transformers_utils/configs/ming_flash_omni.py @@ -27,6 +27,7 @@ logger = logging.get_logger(__name__) class BailingMoeV2Config(PretrainedConfig): model_type = "bailing_moe_v2" + ignore_keys_at_rope_validation = {"mrope_section"} def __init__( self, @@ -237,6 +238,7 @@ class WhisperEncoderConfig(PretrainedConfig): class BailingMM2Config(PretrainedConfig): model_type = "bailingmm_moe_v2_lite" + ignore_keys_at_rope_validation = {"mrope_section"} is_composition = True sub_configs: ClassVar = {"llm_config": AutoConfig} @@ -352,9 +354,13 @@ class MingFlashOmniTalkerConfig(PretrainedConfig): self.campplus_model = campplus_model def get_text_config(self, decoder: bool = False) -> PretrainedConfig: # noqa: ARG002 - if isinstance(self.llm_config, dict): - return PretrainedConfig.from_dict(self.llm_config) - return self.llm_config + # NOTE: transformers v5 runs validators (e.g. validate_token_ids -> get_text_config) + # during PretrainedConfig.__init__, before llm_config is assigned + llm_config = getattr(self, "llm_config", None) + if isinstance(llm_config, dict): + return PretrainedConfig.from_dict(llm_config) + + return llm_config class MingFlashOmniConfig(PretrainedConfig): diff --git a/vllm_omni/transformers_utils/processors/ming.py b/vllm_omni/transformers_utils/processors/ming.py index 7f414b726..685909029 100644 --- a/vllm_omni/transformers_utils/processors/ming.py +++ b/vllm_omni/transformers_utils/processors/ming.py @@ -22,6 +22,21 @@ from transformers import AutoFeatureExtractor, AutoProcessor from transformers.feature_extraction_utils import BatchFeature, FeatureExtractionMixin from transformers.processing_utils import ProcessorMixin from transformers.tokenization_utils_base import PreTokenizedInput, TextInput +from transformers.utils import logging + +try: + from transformers import AutoVideoProcessor +except ImportError: + AutoVideoProcessor = None + +_HAS_VIDEO_PROCESSOR = AutoVideoProcessor is not None + +logger = logging.get_logger(__name__) + + +def raise_missing_video_processor(): + raise ValueError("Ming Flash Omni video inputs require a Transformers 5.x `video_processor`.") + DEFAULT_IMAGE_PATCH_TOKEN = "" DEFAULT_IM_START_TOKEN = "" @@ -156,6 +171,8 @@ class MingFlashOmniProcessor(ProcessorMixin): attributes = ["image_processor", "audio_processor", "tokenizer"] image_processor_class = "AutoImageProcessor" + if _HAS_VIDEO_PROCESSOR: + video_processor_class = "AutoVideoProcessor" audio_processor_class = "AutoFeatureExtractor" tokenizer_class = "AutoTokenizer" @@ -167,6 +184,7 @@ class MingFlashOmniProcessor(ProcessorMixin): merge_size: int = 2, **kwargs, ): + video_processor = kwargs.pop("video_processor", None) # Enforce that all sub-processors exist # Keep None defaults in the signature for HF ProcessorMixin compatibility if image_processor is None: @@ -180,16 +198,47 @@ class MingFlashOmniProcessor(ProcessorMixin): self.image_token = PLACEHOLDER_IMAGE_TOKEN_IN_TEXT self.video_token = PLACEHOLDER_VIDEO_TOKEN_IN_TEXT self.audio_token = PLACEHOLDER_AUDIO_TOKEN_IN_TEXT + if video_processor is not None and not _HAS_VIDEO_PROCESSOR: + raise ValueError("`video_processor` requires transformers with `AutoVideoProcessor` support.") + super().__init__( image_processor=image_processor, audio_processor=audio_processor, tokenizer=tokenizer, ) + self.video_processor = video_processor # Fall back to the tokenizer's own chat_template. if self.chat_template is None: self.chat_template = getattr(tokenizer, "chat_template", None) + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs): + video_processor = kwargs.pop("video_processor", None) + processor = super().from_pretrained(pretrained_model_name_or_path, *args, **kwargs) + if video_processor is not None: + processor.video_processor = video_processor + elif _HAS_VIDEO_PROCESSOR: + try: + processor.video_processor = AutoVideoProcessor.from_pretrained( + pretrained_model_name_or_path, + *args, + **kwargs, + ) + except OSError: + processor.video_processor = None + except (ValueError, KeyError) as exc: + logger.warning("Failed to load optional Ming video processor: %s", exc) + processor.video_processor = None + return processor + + def save_pretrained(self, save_directory, push_to_hub: bool = False, **kwargs): + output = super().save_pretrained(save_directory, push_to_hub=push_to_hub, **kwargs) + video_processor = getattr(self, "video_processor", None) + if video_processor is not None: + video_processor.save_pretrained(save_directory) + return output + def __call__( self, text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput], @@ -211,7 +260,6 @@ class MingFlashOmniProcessor(ProcessorMixin): if images is not None: image_outputs = self.image_processor( images=images, - videos=None, return_tensors="pt", **kwargs.get("images_kwargs", {}), ) @@ -220,12 +268,15 @@ class MingFlashOmniProcessor(ProcessorMixin): text = self._expand_image_tokens(text, image_outputs["image_grid_thw"]) if videos is not None: - video_outputs = self.image_processor( - images=None, - videos=videos, - return_tensors="pt", - **kwargs.get("videos_kwargs", {}), - ) + video_processor = getattr(self, "video_processor", None) + if video_processor is not None: + video_outputs = video_processor( + videos=videos, + return_tensors="pt", + **kwargs.get("videos_kwargs", {}), + ) + else: + raise_missing_video_processor() if "pixel_values" in video_outputs: video_outputs["pixel_values_videos"] = video_outputs.pop("pixel_values") if "image_grid_thw" in video_outputs: @@ -423,6 +474,9 @@ class MingFlashOmniProcessor(ProcessorMixin): + self.image_processor.model_input_names + self.audio_processor.model_input_names ) + video_processor = getattr(self, "video_processor", None) + if video_processor is not None: + names += video_processor.model_input_names return list(dict.fromkeys(names)) From b63b36ded3638d035123fbb0a1558477207c8d3e Mon Sep 17 00:00:00 2001 From: "Guanzhe (Leo) Huang" <116847811+leohuang257@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:51:11 +0800 Subject: [PATCH 05/75] [Feature] gen-only FP8 Quantization Support for SenseNova-U1 (#3943) Signed-off-by: leohuang257 --- docs/user_guide/quantization/fp8.md | 11 ++ .../quantization/test_quantization_fp8.py | 91 +++++++++ .../quantization/test_sensenova_u1_fp8.py | 175 ++++++++++++++++++ .../sensenova_u1/pipeline_sensenova_u1.py | 42 ++++- 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 tests/diffusion/quantization/test_sensenova_u1_fp8.py diff --git a/docs/user_guide/quantization/fp8.md b/docs/user_guide/quantization/fp8.md index c8f14548c..69e161a60 100644 --- a/docs/user_guide/quantization/fp8.md +++ b/docs/user_guide/quantization/fp8.md @@ -52,6 +52,17 @@ guide. FP8 on Ampere may use a weight-only path where available. Audio encoder, vision encoder, talker, and code2wav stay in BF16 unless a model-specific guide says otherwise. +### MoT Diffusion Model (SenseNova-U1) + +| Model | HF models | Scope | Status | Notes | +|-------|-----------|-------|--------|-------| +| SenseNova-U1 | `SenseNova/SenseNova-U1-8B-MoT` | Gen-path only (`*_mot_gen` layers) | Validated | Understanding-path layers stay in BF16; no `ignored_layers` needed | + +SenseNova-U1 uses Mixture-of-Tokenizers (MoT) with duplicate weights per +layer: understanding path (`qkv_proj`, `mlp`) and generation path +(`qkv_proj_mot_gen`, `mlp_mot_gen`). When `quantization="fp8"` is set, +the pipeline automatically applies FP8 only to gen-path GEMMs. + ### Multi-Stage Diffusion Model (BAGEL, GLM-Image) | Model | Scope | Status | Notes | diff --git a/tests/diffusion/quantization/test_quantization_fp8.py b/tests/diffusion/quantization/test_quantization_fp8.py index 58f91742d..8ff16e1f1 100644 --- a/tests/diffusion/quantization/test_quantization_fp8.py +++ b/tests/diffusion/quantization/test_quantization_fp8.py @@ -6,11 +6,13 @@ End-to-end tests for the unified quantization framework (PR #1764). Validates FP8 quantization works correctly for all supported model types: - Single-stage diffusion models (FLUX.1-dev, Qwen-Image, Z-Image-Turbo) - Multi-stage models (BAGEL: LLM + Diffusion) + - MoT gen-only models (SenseNova-U1: gen-path FP8, und-path BF16) Tests verify: 1. FP8 quantization produces valid images 2. Memory usage is lower than BF16 baseline 3. Multi-stage models only quantize the diffusion stage (not the LLM stage) + 4. MoT models only quantize gen-path layers (mot_gen) Usage: # Run all FP8 quantization tests @@ -436,3 +438,92 @@ def test_single_stage_quantization_config_key(): quantization_config="fp8", ) assert len(images) >= 1 + + +# ─── SenseNova-U1 gen-only FP8 tests ───────────────────────────────────────── + +_SENSENOVA_MODEL = "SenseNova/SenseNova-U1-8B-MoT" + + +def _generate_sensenova_u1_image( + quantization_config: str | None = None, + num_inference_steps: int = 20, +) -> tuple[Any, float]: + """Generate an image with SenseNova-U1 (single-stage MoT, gen-only FP8). + + Returns (generated_image, peak_memory_gib). + """ + omni_kwargs: dict[str, Any] = {} + if quantization_config: + omni_kwargs["quantization_config"] = quantization_config + + with OmniRunner(_SENSENOVA_MODEL, **omni_kwargs) as runner: + torch.accelerator.reset_peak_memory_stats() + + sampling_params = OmniDiffusionSamplingParams( + height=1024, + width=1024, + seed=42, + num_inference_steps=num_inference_steps, + extra_args={ + "cfg_scale": 4.0, + "cfg_norm": "none", + "timestep_shift": 3.0, + }, + ) + omni_outputs = list( + runner.omni.generate( + prompts={"prompt": "A cat sitting on a windowsill", "modalities": ["image"]}, + sampling_params_list=sampling_params, + ) + ) + + peak_mem = torch.accelerator.max_memory_allocated() / (1024**3) + + generated_image = None + for req_output in omni_outputs: + if images := getattr(req_output, "images", None): + generated_image = images[0] + break + + assert generated_image is not None, "No images generated from SenseNova-U1" + assert generated_image.size == (1024, 1024), f"Expected 1024x1024, got {generated_image.size}" + + return generated_image, peak_mem + + +@hardware_test(res={"cuda": "H100"}) +def test_sensenova_u1_fp8_generates_image(): + """SenseNova-U1 with gen-only FP8 generates a valid image. + + FP8 is applied only to gen-path (mot_gen) layers; understanding-path + layers stay in BF16. + """ + image, _ = _generate_sensenova_u1_image(quantization_config="fp8") + image.save("test_sensenova_u1_fp8.png") + + +@hardware_test(res={"cuda": "H100"}) +def test_sensenova_u1_bf16_generates_image(): + """SenseNova-U1 without quantization generates a valid image (baseline).""" + image, _ = _generate_sensenova_u1_image(quantization_config=None) + image.save("test_sensenova_u1_bf16.png") + + +@hardware_test(res={"cuda": "H100"}) +def test_sensenova_u1_fp8_uses_less_memory(): + """Gen-only FP8 should use less peak memory than BF16 for SenseNova-U1.""" + _, mem_bf16 = _generate_sensenova_u1_image( + quantization_config=None, + num_inference_steps=10, + ) + torch.accelerator.empty_cache() + + _, mem_fp8 = _generate_sensenova_u1_image( + quantization_config="fp8", + num_inference_steps=10, + ) + + print(f"SenseNova-U1 BF16 peak memory: {mem_bf16:.2f} GiB") + print(f"SenseNova-U1 FP8 peak memory: {mem_fp8:.2f} GiB") + assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)" diff --git a/tests/diffusion/quantization/test_sensenova_u1_fp8.py b/tests/diffusion/quantization/test_sensenova_u1_fp8.py new file mode 100644 index 000000000..4ed30e9a6 --- /dev/null +++ b/tests/diffusion/quantization/test_sensenova_u1_fp8.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""CPU tests for SenseNova-U1 gen-only FP8 quantization. + +Validates that the ``_GenOnlyQuantConfig`` wrapper correctly routes FP8 +quantization to only the gen-path (``*_mot_gen``) layers while leaving +understanding-path layers in BF16. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +import torch +from vllm.distributed import parallel_state +from vllm.model_executor.layers.linear import LinearBase + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +@pytest.fixture(autouse=True) +def _fake_tp_group(): + """Provide a fake TP group so vLLM parallel layers can be instantiated. + + Note: a shared ``init_fake_tp_group`` fixture exists in + ``tests/helpers/fixtures/env.py`` but requires pytest-mock. + """ + mock_tp = MagicMock() + mock_tp.world_size = 1 + mock_tp.rank_in_group = 0 + old = parallel_state._TP + parallel_state._TP = mock_tp + yield + parallel_state._TP = old + + +def _make_minimal_llm_config(num_layers: int = 2) -> SimpleNamespace: + """Create a minimal SenseNova-U1 LLM config for unit testing.""" + return SimpleNamespace( + hidden_size=256, + intermediate_size=512, + num_hidden_layers=num_layers, + num_attention_heads=4, + num_key_value_heads=4, + head_dim=64, + hidden_act="silu", + rms_norm_eps=1e-6, + vocab_size=1024, + rope_theta=1000000.0, + rope_theta_hw=10000.0, + max_position_embeddings=4096, + max_position_embeddings_hw=1024, + attention_bias=True, + layer_types=["full_attention"] * num_layers, + tie_word_embeddings=False, + ) + + +def _build_model_with_gen_only_quant(quant_method: str | None = "fp8"): + """Construct SenseNovaU1ForCausalLM with gen-only FP8 wrapping.""" + from unittest.mock import MagicMock, patch + + from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import ( + _GenOnlyQuantConfig, + ) + from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import ( + SenseNovaU1ForCausalLM, + ) + from vllm_omni.quantization import build_quant_config + + inner_config = build_quant_config(quant_method) + if inner_config is not None: + quant_config = _GenOnlyQuantConfig(inner_config) + else: + quant_config = None + + llm_cfg = _make_minimal_llm_config(num_layers=2) + + mock_vllm_config = MagicMock() + mock_vllm_config.model_config.dtype = torch.bfloat16 + mock_vllm_config.model_config.quantization = quant_method + + with ( + torch.device("meta"), + patch("vllm.model_executor.layers.quantization.fp8.get_current_vllm_config", return_value=mock_vllm_config), + ): + model = SenseNovaU1ForCausalLM( + llm_cfg, + quant_config=quant_config, + prefix="language_model", + ) + return model + + +def test_mot_gen_layers_have_fp8_quant_method(): + """Gen-path (mot_gen) Linear layers should have a real FP8 quant_method.""" + from vllm.model_executor.layers.linear import UnquantizedLinearMethod + + model = _build_model_with_gen_only_quant("fp8") + + mot_gen_linears = [ + (name, mod) for name, mod in model.named_modules() if isinstance(mod, LinearBase) and "mot_gen" in name + ] + + assert len(mot_gen_linears) > 0, "No mot_gen LinearBase modules found" + + for name, mod in mot_gen_linears: + assert mod.quant_method is not None, f"mot_gen layer {name!r} should have quant_method with FP8" + assert not isinstance(mod.quant_method, UnquantizedLinearMethod), ( + f"mot_gen layer {name!r} should NOT use UnquantizedLinearMethod" + ) + + +def test_und_layers_have_no_quant_method(): + """Understanding-path Linear layers should use UnquantizedLinearMethod (stay BF16).""" + from vllm.model_executor.layers.linear import UnquantizedLinearMethod + + model = _build_model_with_gen_only_quant("fp8") + + und_linears = [ + (name, mod) for name, mod in model.named_modules() if isinstance(mod, LinearBase) and "mot_gen" not in name + ] + + assert len(und_linears) > 0, "No und LinearBase modules found" + + for name, mod in und_linears: + assert isinstance(mod.quant_method, UnquantizedLinearMethod), ( + f"und layer {name!r} should use UnquantizedLinearMethod, got {type(mod.quant_method).__name__}" + ) + + +def test_no_quant_method_without_quantization(): + """Without quantization, all layers should use UnquantizedLinearMethod.""" + from vllm.model_executor.layers.linear import UnquantizedLinearMethod + + model = _build_model_with_gen_only_quant(None) + + for name, mod in model.named_modules(): + if isinstance(mod, LinearBase): + assert isinstance(mod.quant_method, UnquantizedLinearMethod), ( + f"LinearBase module {name!r} has quant_method=" + f"{type(mod.quant_method).__name__} " + "but no quantization was configured" + ) + + +def test_und_and_gen_layers_both_present(): + """Sanity check: the model has both und and gen layers.""" + model = _build_model_with_gen_only_quant(None) + + layer_names = [name for name, _ in model.named_modules()] + + und_qkv = [n for n in layer_names if "qkv_proj" in n and "mot_gen" not in n] + gen_qkv = [n for n in layer_names if "qkv_proj_mot_gen" in n] + und_mlp = [n for n in layer_names if ".mlp." in n and "mot_gen" not in n] + gen_mlp = [n for n in layer_names if "mlp_mot_gen" in n] + + assert len(und_qkv) > 0, "No und qkv_proj layers found" + assert len(gen_qkv) > 0, "No gen qkv_proj_mot_gen layers found" + assert len(und_mlp) > 0, "No und mlp layers found" + assert len(gen_mlp) > 0, "No gen mlp_mot_gen layers found" + + +def test_gen_only_wrapper_delegates_attributes(): + """_GenOnlyQuantConfig should delegate attribute access to the inner config.""" + from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import ( + _GenOnlyQuantConfig, + ) + from vllm_omni.quantization import build_quant_config + + inner = build_quant_config("fp8") + wrapper = _GenOnlyQuantConfig(inner) + + assert wrapper.get_name() == "fp8" + assert wrapper.activation_scheme == inner.activation_scheme 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 fe8695b07..917cea13e 100644 --- a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py +++ b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py @@ -31,6 +31,7 @@ import torchvision.transforms as T from PIL import Image from transformers import AutoTokenizer from vllm.logger import init_logger +from vllm.model_executor.layers.quantization import QuantizationConfig from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.utils import get_local_device @@ -483,6 +484,39 @@ def _optimized_scale(positive_flat, negative_flat): return dot / sq_norm +# --------------------------------------------------------------------------- +# Gen-only FP8 wrapper +# --------------------------------------------------------------------------- + + +class _GenOnlyQuantConfig: + """Wraps an inner QuantizationConfig so only ``*_mot_gen`` layers are quantized. + + SenseNova-U1 uses Mixture-of-Tokenizers with duplicate weights per layer: + understanding path (``qkv_proj``, ``mlp``) and generation path + (``qkv_proj_mot_gen``, ``mlp_mot_gen``). The RFC requires FP8 only on the + gen-path GEMMs during denoising; und-path layers stay in BF16. + + This is *not* a full ``QuantizationConfig`` subclass — it only needs to + satisfy the duck-typed interface that ``LinearBase.__init__`` uses: + ``get_quant_method(layer, prefix)``. The outer registry and weight-loader + still operate on the original config stored in ``od_config.quantization_config``. + """ + + def __init__(self, inner: QuantizationConfig): + self._inner = inner + + def get_quant_method(self, layer, prefix: str = ""): + from vllm.model_executor.layers.linear import UnquantizedLinearMethod + + if "mot_gen" in prefix: + return self._inner.get_quant_method(layer, prefix) + return UnquantizedLinearMethod() + + def __getattr__(self, name): + return getattr(self._inner, name) + + # --------------------------------------------------------------------------- # Pipeline # --------------------------------------------------------------------------- @@ -549,9 +583,15 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli self.img_context_token_id = self.tokenizer.convert_tokens_to_ids(IMG_CONTEXT_TOKEN) self.img_start_token_id = self.tokenizer.convert_tokens_to_ids(IMG_START_TOKEN) - # Language model (TP-aware) + # Language model (TP-aware). + # Wrap quant_config so only gen-path (mot_gen) layers are quantized; + # understanding-path layers stay in BF16. + quant_config = od_config.quantization_config + if quant_config is not None: + quant_config = _GenOnlyQuantConfig(quant_config) self.language_model = SenseNovaU1ForCausalLM( self.llm_cfg, + quant_config=quant_config, prefix="language_model", ) From 3875b81c1825a644df4b0779b800dfbe1f7db7fa Mon Sep 17 00:00:00 2001 From: Shylin Date: Fri, 5 Jun 2026 08:51:38 +0530 Subject: [PATCH 06/75] [skip ci]cleanup(assets): remove dead vllm_omni/assets/video.py (#4120) Signed-off-by: Shylin26 --- vllm_omni/assets/video.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 vllm_omni/assets/video.py diff --git a/vllm_omni/assets/video.py b/vllm_omni/assets/video.py deleted file mode 100644 index 6a5f3204a..000000000 --- a/vllm_omni/assets/video.py +++ /dev/null @@ -1,16 +0,0 @@ -import numpy as np -from vllm.assets.video import VideoAsset -from vllm.multimodal.media.audio import load_audio - - -def extract_video_audio(path: str = None, sampling_rate: int = 16000) -> np.ndarray: - """This function extracts the audio from a video file path and returns the audio as a numpy array. - Args: - path: The path to the video file. - Returns: - The audio as a numpy array. - """ - if not path: - path = VideoAsset(name="baby_reading").video_path - audio_signal, sr = load_audio(path, sr=sampling_rate) - return audio_signal From 1e43656ab1dec8f689b2d57122e66d7379cbaf06 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Fri, 5 Jun 2026 11:39:07 +0800 Subject: [PATCH 07/75] [Bugfix] Update the value of --max-seed-tts-mean-wer in the accuracy test. (#4160) Signed-off-by: amy-why-3459 --- .../qwen3_omni/run_qwen_omni_acc_benchmark.py | 2 +- vllm_omni/metrics/utils.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py b/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py index 5e872e73a..8ce6f1988 100644 --- a/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py +++ b/tests/e2e/accuracy/qwen3_omni/run_qwen_omni_acc_benchmark.py @@ -340,7 +340,7 @@ def build_arg_parser() -> argparse.ArgumentParser: p.add_argument( "--max-seed-tts-mean-wer", type=float, - default=0.25, + default=0.35, help="If set, fail when seed_tts_content_error_mean is strictly above this value.", ) p.add_argument( diff --git a/vllm_omni/metrics/utils.py b/vllm_omni/metrics/utils.py index 1ed987a19..2062eb5ba 100644 --- a/vllm_omni/metrics/utils.py +++ b/vllm_omni/metrics/utils.py @@ -94,7 +94,13 @@ def _format_table( if isinstance(value, float): return f"{value:,.3f}" if isinstance(value, list): - return ", ".join(str(f"{v:,.3f}") for v in value) + if not value: + return "" + try: + avg = sum(float(v) for v in value) / len(value) + return f"{avg:,.3f} (n={len(value)})" + except (TypeError, ValueError): + return ", ".join(str(v) for v in value) return str(value) table = PrettyTable() @@ -117,6 +123,17 @@ def _format_table( if column_key is None: raise ValueError("column_key is required for multi-column mode") col_headers = [f"{column_prefix}{row.get(column_key, '?')}" for row in data] + # PrettyTable requires unique field names. When the same column key + # appears more than once (e.g. two events with the same stage_id), + # deduplicate by appending _2, _3, … to the later occurrences. + if len(col_headers) != len(set(col_headers)): + seen: dict[str, int] = {} + deduped: list[str] = [] + for h in col_headers: + n = seen.get(h, 0) + seen[h] = n + 1 + deduped.append(h if n == 0 else f"{h}_{n + 1}") + col_headers = deduped table.field_names = ["Field"] + col_headers table.align["Field"] = "l" for col in col_headers: From 84569280b1db648c5ab739db9c9656b9a598c352 Mon Sep 17 00:00:00 2001 From: Yaegaki1Erika <70182590+Yaegaki1Erika@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:47:25 +0800 Subject: [PATCH 08/75] [Bugfix]Fix HunyuanImage3 conditional image and prompt kwargs alignment (#4145) Signed-off-by: reandeng Signed-off-by: Yaegaki1Erika <70182590+Yaegaki1Erika@users.noreply.github.com> Co-authored-by: reandeng --- ...test_serving_chat_multistage_generation.py | 2 +- .../hunyuan_image3/pipeline_hunyuan_image3.py | 37 ++++++++++++++----- vllm_omni/entrypoints/openai/api_server.py | 10 +++++ vllm_omni/entrypoints/openai/serving_chat.py | 2 + .../models/hunyuan_image3/hunyuan_image3.py | 22 ++++++++--- 5 files changed, 56 insertions(+), 17 deletions(-) mode change 100644 => 100755 vllm_omni/entrypoints/openai/api_server.py mode change 100644 => 100755 vllm_omni/entrypoints/openai/serving_chat.py mode change 100644 => 100755 vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py diff --git a/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py b/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py index c8bb050e7..507018ef7 100644 --- a/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py +++ b/tests/entrypoints/openai_api/test_serving_chat_multistage_generation.py @@ -61,7 +61,7 @@ def test_build_multistage_generation_inputs_applies_stage_specific_overrides(ser assert engine_prompt["prompt"] == "draw a robot" assert engine_prompt["modalities"] == ["img2img"] assert engine_prompt["negative_prompt"] == "blurry" - assert engine_prompt["mm_processor_kwargs"] == {"target_h": 768, "target_w": 1024} + assert engine_prompt["mm_processor_kwargs"] == {"target_h": 768, "target_w": 1024, "vae_generator_seed": 0} assert engine_prompt["multi_modal_data"]["img2img"].size == (24, 24) assert len(sampling_params_list) == 3 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 9cc2aafe5..988b91920 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py @@ -626,7 +626,7 @@ class HunyuanImage3Pipeline( rope_image_info.append(list(zip(image_slices, image_shapes))) return rope_image_info - def vae_encode(self, image, cfg_factor=1): + def vae_encode(self, image, cfg_factor=1, generator=None): config = self.vae.config if image.ndim == 3: @@ -641,11 +641,7 @@ class HunyuanImage3Pipeline( if isinstance(vae_encode_result, torch.Tensor): latents = vae_encode_result else: - # Match HunyuanImage-3's cond encode path: sample the - # posterior, but use a fixed generator so repeated online - # requests are deterministic. - _cond_vae_gen = torch.Generator(device=image.device).manual_seed(0) - latents = vae_encode_result.latent_dist.sample(_cond_vae_gen) + latents = vae_encode_result.latent_dist.sample(generator) if hasattr(config, "shift_factor") and config.shift_factor: latents.sub_(config.shift_factor) if hasattr(config, "scaling_factor") and config.scaling_factor: @@ -668,14 +664,17 @@ class HunyuanImage3Pipeline( self, batch_cond_image_info_list: list[list[JointImageInfo]], cfg_factor: int = 1, + generator=None, ): # VAE encode one by one, as we assume cond images have different sizes batch_cond_vae_images, batch_cond_t, batch_cond_vit_images = [], [], [] - for cond_image_info_list in batch_cond_image_info_list: + for batch_idx, cond_image_info_list in enumerate(batch_cond_image_info_list): cond_vae_image_list, cond_t_list, cond_vit_image_list = [], [], [] + cond_generator = generator[batch_idx] if isinstance(generator, list) else generator for image_info in cond_image_info_list: cond_t_, cond_vae_image_ = self.vae_encode( image_info.vae_image_info.image_tensor.to(self.device), + generator=cond_generator, ) cond_vit_image_list.append(image_info.vision_image_info.image_tensor) cond_vae_image_list.append(cond_vae_image_.squeeze(0)) @@ -869,7 +868,7 @@ class HunyuanImage3Pipeline( has_ar_kv = kwargs.get("ar_kv_data") if batch_cond_image_info is not None and len(batch_cond_image_info[0]) > 0 and not has_ar_kv: cond_vae_images, cond_timestep, cond_vit_images = self._encode_cond_image( - batch_cond_image_info, cfg_factor[mode] + batch_cond_image_info, cfg_factor[mode], generator=generator ) vit_kwargs = {"spatial_shapes": [], "attention_mask": []} for cond_image_info in batch_cond_image_info: @@ -1393,18 +1392,23 @@ class HunyuanImage3Pipeline( **kwargs, ) -> DiffusionOutput: extra_args = getattr(getattr(req, "sampling_params", None), "extra_args", {}) or {} + bot_task = extra_args.get("bot_task") use_system_prompt = extra_args.get("use_system_prompt") system_prompt = extra_args.get("system_prompt") - # Fall back to per-prompt use_system_prompt forwarded by ar2diffusion + # Fall back to per-prompt prompt kwargs forwarded by ar2diffusion if req.prompts: first_prompt = req.prompts[0] if isinstance(first_prompt, dict): + if bot_task is None: + bot_task = first_prompt.get("bot_task") if use_system_prompt is None: use_system_prompt = first_prompt.get("use_system_prompt") if system_prompt is None: system_prompt = first_prompt.get("system_prompt") + if isinstance(bot_task, str) and bot_task.lower() == "none": + bot_task = None if use_system_prompt is not None: - system_prompt = get_system_prompt(use_system_prompt, "image", system_prompt) + system_prompt = get_system_prompt(use_system_prompt, bot_task or "image", system_prompt) system_prompt = system_prompt.strip() if system_prompt is not None else "" prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt @@ -1451,6 +1455,18 @@ class HunyuanImage3Pipeline( # ---- AR KV Reuse: extract injected KV from request ---- ar_kv_kwargs = self._extract_ar_kv_from_request(req) + tokenizer_bot_task = bot_task + if tokenizer_bot_task == "think_recaption": + tokenizer_bot_task = "think" + elif tokenizer_bot_task == "vanilla": + tokenizer_bot_task = "image" + supported_bot_tasks = {"auto", "image", "think", "recaption", "img_ratio"} + if tokenizer_bot_task is not None and tokenizer_bot_task not in supported_bot_tasks: + raise ValueError( + f"Unsupported HunyuanImage3 single-stage bot_task: {tokenizer_bot_task!r}. " + f"Supported values are: {sorted(supported_bot_tasks)}." + ) + model_inputs = self.prepare_model_inputs( prompt=prompt, cot_text=cot_text, @@ -1461,6 +1477,7 @@ class HunyuanImage3Pipeline( num_inference_steps=num_inference_steps, guidance_scale=guidance_scale, batch_cond_image_info=batch_cond_image_info, + bot_task=tokenizer_bot_task or "auto", **ar_kv_kwargs, ) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py old mode 100644 new mode 100755 index 1be7aceba..7c6d7b9e5 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -2015,6 +2015,16 @@ async def edit_images( _update_if_not_none(gen_params, "layers", layers) _update_if_not_none(gen_params, "resolution", resolution) + extra_args = dict(getattr(gen_params, "extra_args", {}) or {}) + if bot_task is not None: + extra_args["bot_task"] = bot_task + if sys_type is not None: + extra_args["use_system_prompt"] = sys_type + if system_prompt is not None: + extra_args["system_prompt"] = system_prompt + if extra_args: + gen_params.extra_args = extra_args + # 4. Generate images request_id = f"img_edit-{random_uuid()}" raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id) diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py old mode 100644 new mode 100755 index c7b24e11f..9bac6c9ba --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -2599,6 +2599,8 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): mm_processor_kwargs["target_h"] = height if width is not None: mm_processor_kwargs["target_w"] = width + if seed is not None and engine_prompt_data is not None: + mm_processor_kwargs["vae_generator_seed"] = int(seed) if mm_processor_kwargs: engine_prompt["mm_processor_kwargs"] = mm_processor_kwargs if engine_prompt_data is not None: diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py old mode 100644 new mode 100755 index 7770bee95..7b975796f --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -1043,6 +1043,9 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc images = mm_data.get("images", []) logger.debug(f"process image count: {len(images)}") batch_feature = image_processor(prompt, images, **tok_kwargs) + vae_generator_seed = mm_kwargs.get("vae_generator_seed") + if vae_generator_seed is not None and images: + batch_feature["vae_generator_seed"] = torch.full((len(images),), int(vae_generator_seed), dtype=torch.long) return batch_feature def _hf_processor_applies_updates( @@ -1080,6 +1083,8 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc config["base_size"] = MultiModalFieldConfig.batched("image") if "ratio_index" in hf_inputs: config["ratio_index"] = MultiModalFieldConfig.batched("image") + if "vae_generator_seed" in hf_inputs: + config["vae_generator_seed"] = MultiModalFieldConfig.batched("image") return config def _get_prompt_updates( @@ -1732,6 +1737,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo # we reconstruct per-image shapes from vae_token_grid_hw below. kwargs.pop("vae_pixel_size", None) vae_token_grid_hw = kwargs.pop("vae_token_grid_hw", None) + vae_generator_seed = kwargs.pop("vae_generator_seed", None) if vit_pixel_values is None or vae_pixel_values is None: return None @@ -1771,6 +1777,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo "vit_spatial_shapes": vit_spatial_shapes, "vae_pixel_values": vae_image_list, "vae_token_grid_hw": vae_token_grid_hw, + "vae_generator_seed": vae_generator_seed, }, ) @@ -1778,6 +1785,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo self, images: torch.Tensor, cfg_factor: int = 1, + generator: torch.Generator | None = None, ) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]: """ Encode images through VAE encoder. @@ -1797,11 +1805,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo images = images.to(dtype=self.vae.dtype) vae_encode_result = self.vae.encode(images) - # Match HunyuanImage-3's cond encode path: sample the posterior, but - # use a fixed generator so online requests do not consume the global - # RNG and drift across a long-running server. - _cond_vae_gen = torch.Generator(device=images.device).manual_seed(0) - latents = vae_encode_result.latent_dist.sample(_cond_vae_gen) + latents = vae_encode_result.latent_dist.sample(generator) # Apply shift and scaling factors if present if hasattr(config, "shift_factor") and config.shift_factor: @@ -1933,6 +1937,12 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo vit_pixel_attention_mask = pixel_values["vit_pixel_attention_mask"] vit_spatial_shapes = pixel_values["vit_spatial_shapes"] vae_pixel_values = pixel_values["vae_pixel_values"] + vae_generator_seed = pixel_values.get("vae_generator_seed") + vae_generator = None + if vae_generator_seed is not None and vae_generator_seed.numel() > 0: + vae_generator = torch.Generator(device=vae_pixel_values[0].device).manual_seed( + int(vae_generator_seed.reshape(-1)[0].item()) + ) # Perform ViT encoding vit_embeddings = self._vit_encode(vit_pixel_values, vit_pixel_attention_mask, vit_spatial_shapes) @@ -1941,7 +1951,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo # `reso_group` bucket so shapes are ragged across the image-batch dim. vae_token_embeddings = [] for vae_image_i in vae_pixel_values: - t_i, latents_i = self._vae_encode(vae_image_i.unsqueeze(0), vae_cfg_factor) + t_i, latents_i = self._vae_encode(vae_image_i.unsqueeze(0), vae_cfg_factor, generator=vae_generator) t_emb = self.time_embed(t_i[0]) vae_tokens, _, _ = self.patch_embed(latents_i, t_emb) vae_token_embeddings.append(vae_tokens) From 6cefe913703fa67a0726c327476334fe391154d0 Mon Sep 17 00:00:00 2001 From: Jianbao Huang <51468896+Bill845514379@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:41:56 +0800 Subject: [PATCH 09/75] [Perf] Enable fused RMSNorm for HunyuanImage3 (#3959) --- benchmarks/accuracy/common.py | 9 +++ .../accuracy/image_to_image/gedit_bench.py | 77 ++++++++++++++++++- tests/benchmarks/test_accuracy_bench_utils.py | 8 ++ .../hunyuan_image3_transformer.py | 2 +- vllm_omni/entrypoints/openai/api_server.py | 34 ++++++-- 5 files changed, 119 insertions(+), 11 deletions(-) diff --git a/benchmarks/accuracy/common.py b/benchmarks/accuracy/common.py index bce0b2359..bd28303b4 100644 --- a/benchmarks/accuracy/common.py +++ b/benchmarks/accuracy/common.py @@ -151,6 +151,9 @@ class VllmOmniImageClient: seed: int | None = None, negative_prompt: str | None = None, output_compression: int | None = None, + bot_task: str | None = None, + sys_type: str | None = None, + system_prompt: str | None = None, ) -> Image.Image: if not isinstance(images, list): images = [images] @@ -170,6 +173,12 @@ class VllmOmniImageClient: data["negative_prompt"] = negative_prompt if output_compression is not None: data["output_compression"] = str(output_compression) + if bot_task is not None: + data["bot_task"] = bot_task + if sys_type is not None: + data["sys_type"] = sys_type + if system_prompt is not None: + data["system_prompt"] = system_prompt files = [ ( diff --git a/benchmarks/accuracy/image_to_image/gedit_bench.py b/benchmarks/accuracy/image_to_image/gedit_bench.py index adb072c0a..2991bd4b5 100644 --- a/benchmarks/accuracy/image_to_image/gedit_bench.py +++ b/benchmarks/accuracy/image_to_image/gedit_bench.py @@ -64,6 +64,47 @@ def resolve_model_name(*, model_name: str | None, model: str | None = None, outp raise ValueError("model-name is required when it cannot be inferred from model or output-root") +def _coerce_score_int(value: Any) -> int: + if isinstance(value, bool): + raise TypeError(f"Expected numeric score, got bool: {value!r}") + if isinstance(value, (int, float)): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if stripped.lstrip("-").isdigit(): + return int(stripped) + raise TypeError(f"Expected numeric score, got {type(value).__name__}: {value!r}") + + +def _expand_score_entry(entry: Any) -> list[int]: + if isinstance(entry, dict): + if "score" in entry: + return [_coerce_score_int(entry["score"])] + if "value" in entry: + return [_coerce_score_int(entry["value"])] + numeric_values = [_coerce_score_int(value) for value in entry.values()] + if numeric_values: + return numeric_values + raise TypeError(f"Score dict has no numeric values: {entry!r}") + return [_coerce_score_int(entry)] + + +def _normalize_score_list(score: Any) -> list[int]: + if isinstance(score, dict): + entries: list[Any] = [score] + elif isinstance(score, list): + entries = score + else: + entries = [score] + + normalized: list[int] = [] + for entry in entries: + normalized.extend(_expand_score_entry(entry)) + if not normalized: + raise ValueError(f"Empty score list after normalization: {score!r}") + return normalized + + def parse_score_payload(raw_text: str) -> dict[str, Any]: try: parsed = extract_json_object(raw_text) @@ -73,10 +114,7 @@ def parse_score_payload(raw_text: str) -> dict[str, Any]: parsed = {"score": json.loads(stripped), "reasoning": ""} else: raise - score = parsed.get("score", []) - if not isinstance(score, list): - score = [score] - parsed["score"] = [int(value) for value in score] + parsed["score"] = _normalize_score_list(parsed.get("score", [])) return parsed @@ -391,6 +429,9 @@ class GEditBenchRunner: num_inference_steps: int = 20, guidance_scale: float | None = None, seed: int | None = 42, + bot_task: str | None = None, + sys_type: str | None = None, + system_prompt: str | None = None, ): self.dataset_ref = dataset_ref self.output_root = output_root @@ -400,6 +441,9 @@ class GEditBenchRunner: self.num_inference_steps = num_inference_steps self.guidance_scale = guidance_scale self.seed = seed + self.bot_task = bot_task + self.sys_type = sys_type + self.system_prompt = system_prompt self.client = VllmOmniImageClient(base_url=base_url, api_key=api_key) def generate( @@ -478,6 +522,9 @@ class GEditBenchRunner: num_inference_steps=self.num_inference_steps, guidance_scale=self.guidance_scale, seed=self.seed, + bot_task=self.bot_task, + sys_type=self.sys_type, + system_prompt=self.system_prompt, ) edited_image.save(output_path) return { @@ -633,6 +680,25 @@ def build_parser() -> argparse.ArgumentParser: generate.add_argument("--num-inference-steps", type=int, default=20) generate.add_argument("--guidance-scale", type=float, default=None) generate.add_argument("--seed", type=int, default=42) + generate.add_argument( + "--bot-task", + type=str, + default=None, + choices=["think", "recaption", "think_recaption", "vanilla"], + help="HunyuanImage-3 prompting mode for /v1/images/edits (optional).", + ) + generate.add_argument( + "--sys-type", + type=str, + default=None, + help="Hunyuan system-prompt type (e.g. en_unified). Defaults from --bot-task when omitted.", + ) + generate.add_argument( + "--system-prompt", + type=str, + default=None, + help="Custom Hunyuan system prompt body (sys_type=custom).", + ) generate.add_argument("--workers", type=int, default=1) generate.add_argument("--max-samples", type=int, default=None) generate.add_argument("--samples-per-group", type=int, default=None) @@ -675,6 +741,9 @@ def main(argv: list[str] | None = None) -> int: num_inference_steps=args.num_inference_steps, guidance_scale=args.guidance_scale, seed=args.seed, + bot_task=args.bot_task, + sys_type=args.sys_type, + system_prompt=args.system_prompt, ) records = runner.generate( model_name=model_name, diff --git a/tests/benchmarks/test_accuracy_bench_utils.py b/tests/benchmarks/test_accuracy_bench_utils.py index 6ceebb11b..222b8c2c7 100644 --- a/tests/benchmarks/test_accuracy_bench_utils.py +++ b/tests/benchmarks/test_accuracy_bench_utils.py @@ -345,6 +345,14 @@ def test_parse_score_payload_handles_raw_json_and_delimited_json(): assert parse_score_payload(wrapped)["score"] == [6] +def test_parse_score_payload_handles_qwen_vl_nested_score_dicts(): + qwen_style = '{"score": [{"naturalness": 8, "artifact_free": 7}], "reasoning": "good quality"}' + assert parse_score_payload(qwen_style)["score"] == [8, 7] + + nested_values = '{"score": [{"score": 9}, {"score": 6}], "reasoning": "ok"}' + assert parse_score_payload(nested_values)["score"] == [9, 6] + + def test_summarize_gedit_generated_records_groups_by_task_and_language(): records = [] for group in GEDIT_GROUPS[:2]: 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 80bb1d7b6..6f4ff1db4 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py @@ -32,7 +32,6 @@ from vllm.distributed import ( get_tensor_model_parallel_world_size, ) from vllm.model_executor.layers.activation import SiluAndMul -from vllm.model_executor.layers.layernorm import RMSNorm from vllm.model_executor.layers.linear import ( MergedColumnParallelLinear, QKVParallelLinear, @@ -73,6 +72,7 @@ from vllm_omni.diffusion.distributed.sp_plan import ( ) 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.layers.norm import RMSNorm from vllm_omni.diffusion.layers.rope import RotaryEmbedding from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import HunyuanFusedMoE from vllm_omni.model_executor.layers.timestep_embedding import timestep_embedding diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 7c6d7b9e5..38acd6d55 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -2016,12 +2016,12 @@ async def edit_images( _update_if_not_none(gen_params, "resolution", resolution) extra_args = dict(getattr(gen_params, "extra_args", {}) or {}) - if bot_task is not None: - extra_args["bot_task"] = bot_task - if sys_type is not None: - extra_args["use_system_prompt"] = sys_type - if system_prompt is not None: - extra_args["system_prompt"] = system_prompt + edit_extra_args = _build_hunyuan_edit_extra_args( + bot_task=bot_task, + sys_type=sys_type, + system_prompt=system_prompt, + ) + extra_args.update(edit_extra_args) if extra_args: gen_params.extra_args = extra_args @@ -2323,6 +2323,28 @@ def _check_max_generated_image_size( ) +def _build_hunyuan_edit_extra_args( + *, + bot_task: str | None, + sys_type: str | None, + system_prompt: str | None, +) -> dict[str, Any]: + """Map Hunyuan /v1/images/edits form fields to DiT ``extra_args``.""" + extra_args: dict[str, Any] = {} + effective_use_system_prompt = sys_type + if effective_use_system_prompt is None and bot_task is not None: + from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import resolve_sys_type + + effective_use_system_prompt = resolve_sys_type(bot_task) + if effective_use_system_prompt is not None: + extra_args["use_system_prompt"] = effective_use_system_prompt + if system_prompt is not None: + extra_args["system_prompt"] = system_prompt + if bot_task is not None: + extra_args["bot_task"] = bot_task + return extra_args + + def _update_if_not_none(object: Any, key: str, val: Any) -> None: if val is not None: setattr(object, key, val) From b3dd0a4bafe06665065e86de4d85e09708188981 Mon Sep 17 00:00:00 2001 From: Chenguang Zheng <645327136@qq.com> Date: Fri, 5 Jun 2026 17:22:53 +0800 Subject: [PATCH 10/75] [Fix] Update Qwen3 Omni multi-replica perf baselines (#4175) Signed-off-by: Chenguang ZHENG <645327136@qq.com> --- .../tests/test_qwen3_omni_multi_replicas.json | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json b/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json index 0e7cb63f7..bee799a77 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json @@ -23,10 +23,10 @@ "ignore_eos": true, "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_ttft_ms": [1000, 2000, 3000, 4000], - "mean_tpot_ms": [200, 200, 200, 200], - "mean_audio_ttfp_ms": [10000, 15000, 20000, 25000], - "mean_audio_rtf": [1.5, 1.5, 1.5, 1.5] + "mean_ttft_ms": [203, 283, 470, 869], + "mean_tpot_ms": [28, 36, 41, 45], + "mean_audio_ttfp_ms": [861, 1200, 1528, 2100], + "mean_audio_rtf": [0.24, 0.30, 0.35, 0.39] } }, { @@ -49,9 +49,9 @@ }, "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_ttft_ms": [2000], - "mean_audio_ttfp_ms": [2000], - "mean_audio_rtf": [0.25] + "mean_ttft_ms": [377], + "mean_audio_ttfp_ms": [684], + "mean_audio_rtf": [0.14] } }, { @@ -76,9 +76,9 @@ }, "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_ttft_ms": [6000], - "mean_audio_ttfp_ms": [6000], - "mean_audio_rtf": [0.7] + "mean_ttft_ms": [228], + "mean_audio_ttfp_ms": [686], + "mean_audio_rtf": [0.16] } }, { @@ -105,9 +105,9 @@ }, "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", "baseline": { - "mean_ttft_ms": [12000], - "mean_audio_ttfp_ms": [12000], - "mean_audio_rtf": [1.0] + "mean_ttft_ms": [522], + "mean_audio_ttfp_ms": [1119], + "mean_audio_rtf": [0.21] } } ] From 2414b6f94db7d8ed657fa3febe7bec13a50619f6 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: Fri, 5 Jun 2026 22:07:26 +0800 Subject: [PATCH 11/75] [CI][bugfix]: Improve Qwen Image accuracy test with diffusers' RMSNorm (#4143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Signed-off-by: Zeyu Huang | 黃澤宇 <11222265+fhfuih@users.noreply.github.com> Signed-off-by: Gao Han Co-authored-by: Gao Han --- pyproject.toml | 3 ++- tests/e2e/accuracy/test_diffusers_backend_similarity.py | 2 +- tests/e2e/accuracy/test_qwen_image.py | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0950b2682..19cdc0a26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,8 @@ dev = [ "orjson>=3.11.7", # to make v1/embeddings API fast "voxcpm>=2.0.2", # for VoxCPM2 path-locating in tests "torchdiffeq>=0.2.5", # for covo_audio Test - "FlagEmbedding" # for hunyuan_image3 tests + "FlagEmbedding", # for hunyuan_image3 tests + "kernels<0.15.0" # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0) ] demo = [ diff --git a/tests/e2e/accuracy/test_diffusers_backend_similarity.py b/tests/e2e/accuracy/test_diffusers_backend_similarity.py index 26b39ea78..ad4dd5726 100644 --- a/tests/e2e/accuracy/test_diffusers_backend_similarity.py +++ b/tests/e2e/accuracy/test_diffusers_backend_similarity.py @@ -218,7 +218,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> tuple[Image.I torch_dtype=torch.bfloat16, trust_remote_code=True, ).to("cuda") - + pipe.transformer.set_attention_backend("_flash_3_hub") _diffusers_dummy_run(pipe) generator = torch.Generator(device="cuda").manual_seed(SEED) diff --git a/tests/e2e/accuracy/test_qwen_image.py b/tests/e2e/accuracy/test_qwen_image.py index c9a09e6de..2b513ecda 100644 --- a/tests/e2e/accuracy/test_qwen_image.py +++ b/tests/e2e/accuracy/test_qwen_image.py @@ -106,6 +106,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") + pipe.transformer.set_attention_backend("_flash_3_hub") generator = torch.Generator(device="cuda").manual_seed(SEED) result = pipe( # pyright: ignore[reportCallIssue] prompt=PROMPT, @@ -167,6 +168,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") + 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, From e25239c2ecde8a3c07c9d2e624a1c13761551872 Mon Sep 17 00:00:00 2001 From: ChenWenjing <54166744+Shirley125@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:05:07 +0800 Subject: [PATCH 12/75] [bugfix] fix realtime ci timeout error (#4187) Signed-off-by: CHEN <116010019@link.cuhk.edu.cn> --- vllm_omni/core/sched/omni_ar_scheduler.py | 22 +++++++++---- vllm_omni/engine/orchestrator.py | 31 ++++++++++++++++++- .../entrypoints/openai/realtime_connection.py | 9 ++++-- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index a6d5edd8f..67514994e 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -13,7 +13,7 @@ from vllm.v1.core.sched.async_scheduler import AsyncScheduler as AsyncVLLMSchedu from vllm.v1.core.sched.output import SchedulerOutput from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler from vllm.v1.core.sched.utils import remove_all -from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs +from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs, FinishReason from vllm.v1.metrics.perf import PerfStats from vllm.v1.outputs import ModelRunnerOutput from vllm.v1.request import Request, RequestStatus, StreamingUpdate @@ -69,6 +69,8 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): # alive. Stopped on the first scheduler step after extraction ack. self.pending_stop_after_extraction: set[str] = set() + self.finished_req_ids_dict = defaultdict(set) + # [Omni] Pre-parse KV transfer criteria self.kv_transfer_criteria = self._get_kv_transfer_criteria() @@ -556,16 +558,24 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): # outputs in this step. engine_core_outputs = {client_index: EngineCoreOutputs(outputs=outs) for client_index, outs in outputs.items()} + # FIXME: finished_req_ids_dict is unconditionally initialized as + # defaultdict(set) in __init__ (not gated by include_finished_set). + # This branch is therefore always eligible once any client_index is + # populated; revisit when wiring streaming-only / upstream semantics. finished_req_ids = self.finished_req_ids_dict if finished_req_ids: # Include ids of requests that finished since last outputs # were sent. for client_index, finished_set in finished_req_ids.items(): - # Set finished request set in EngineCoreOutputs for this client. - if (eco := engine_core_outputs.get(client_index)) is not None: - eco.finished_requests = finished_set - else: - engine_core_outputs[client_index] = EngineCoreOutputs(finished_requests=finished_set) + eco = engine_core_outputs.get(client_index) + if eco is None: + eco = EngineCoreOutputs() + engine_core_outputs[client_index] = eco + emitted = {o.request_id for o in eco.outputs} + for req_id in finished_set: + if req_id not in emitted: + eco.outputs.append(EngineCoreOutput(req_id, [], finish_reason=FinishReason.ABORT)) + eco.finished_requests = finished_set finished_req_ids.clear() if (stats := self.make_stats(spec_decoding_stats, kv_connector_stats, cudagraph_stats, perf_stats)) is not None: diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index 017362812..ea20c650b 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -697,6 +697,8 @@ class Orchestrator: "new_prompt_len_snapshot", None, ) + if req_state.streaming.enabled: + await self._apply_raw_terminal_stage_finish(stage_id, eco, req_state) # OmniSchedulerMixin.make_stats() already throttles # per-scheduler at 1 Hz, so raw_outputs.scheduler_stats # being non-None means this replica passed its own gate. @@ -829,6 +831,34 @@ class Orchestrator: if self.request_states.pop(request_id, None) is not None and self._running_counter is not None: self._running_counter.decrement() + async def _apply_raw_terminal_stage_finish( + self, + stage_id: int, + eco: Any, + req_state: OrchestratorRequestState, + ) -> None: + """Record session-level finish markers dropped by the streaming output processor. + + Streaming segment stops set ``is_segment_finished=True`` and are handled + via processed outputs. Session termination (e.g. ``finish_requests`` after + ``resumable=False``) emits a terminal ``finish_reason`` with + ``is_segment_finished=False``, but vLLM's output processor may remove the + request state before that EngineCoreOutput is processed. + + Only update ``finished_final_output_stage_ids`` here. Request cleanup stays + in ``_route_output`` so downstream async-chunk stages can still deliver + outputs after stage-0 session end. + """ + if getattr(eco, "finish_reason", None) is None: + return + if getattr(eco, "is_segment_finished", False): + return + + final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id} + if stage_id not in final_output_stage_ids: + return + req_state.finished_final_output_stage_ids.add(stage_id) + def _maybe_clone_diffusion_params_for_cfg(self, request_id: str, params: Any) -> Any: """Attach CFG companion ids to diffusion sampling params when needed.""" companion_request_ids = self._cfg_tracker.get_companion_request_ids(request_id) @@ -872,7 +902,6 @@ class Orchestrator: req_state.finished_final_output_stage_ids.add(stage_id) final_output_stage_ids = req_state.final_output_stage_ids or {req_state.final_stage_id} request_finished = final_output_stage_ids.issubset(req_state.finished_final_output_stage_ids) - if self.stage_pools[stage_id].final_output: await self.output_async_queue.put( OutputMessage( diff --git a/vllm_omni/entrypoints/openai/realtime_connection.py b/vllm_omni/entrypoints/openai/realtime_connection.py index 1bc3df64e..5cf936e48 100644 --- a/vllm_omni/entrypoints/openai/realtime_connection.py +++ b/vllm_omni/entrypoints/openai/realtime_connection.py @@ -86,9 +86,14 @@ class RealtimeConnection(VllmRealtimeConnection): return [], 24000 sr = mm.get("sr") or mm.get("sample_rate") or mm.get("audio_sample_rate") or 24000 + if isinstance(sr, (list, tuple)) and sr: + sr = sr[-1] + if hasattr(sr, "item"): + sr = sr.item() + sample_rate_hz = int(sr) key = "audio" if "audio" in mm else ("model_outputs" if "model_outputs" in mm else None) if key is None: - return [], int(sr) + return [], sample_rate_hz raw_audio = mm.get(key) chunks: list[np.ndarray] = [] @@ -101,7 +106,7 @@ class RealtimeConnection(VllmRealtimeConnection): arr = self._tensor_to_numpy(raw_audio) if arr is not None and arr.size > 0: chunks.extend(self._raw_waveform_to_deltas(arr)) - return chunks, int(sr) + return chunks, sample_rate_hz @staticmethod def _pcm16_b64(audio_f32: np.ndarray) -> str: From 963ba1ab7a02b2c1f687285fcd2072cd57cd0b34 Mon Sep 17 00:00:00 2001 From: Gao Han Date: Sat, 6 Jun 2026 08:39:10 +0800 Subject: [PATCH 13/75] [CI] Revert "[Feature] gen-only FP8 Quantization Support for SenseNova-U1" (#4196) --- docs/user_guide/quantization/fp8.md | 11 -- .../quantization/test_quantization_fp8.py | 91 --------- .../quantization/test_sensenova_u1_fp8.py | 175 ------------------ .../sensenova_u1/pipeline_sensenova_u1.py | 42 +---- 4 files changed, 1 insertion(+), 318 deletions(-) delete mode 100644 tests/diffusion/quantization/test_sensenova_u1_fp8.py diff --git a/docs/user_guide/quantization/fp8.md b/docs/user_guide/quantization/fp8.md index 69e161a60..c8f14548c 100644 --- a/docs/user_guide/quantization/fp8.md +++ b/docs/user_guide/quantization/fp8.md @@ -52,17 +52,6 @@ guide. FP8 on Ampere may use a weight-only path where available. Audio encoder, vision encoder, talker, and code2wav stay in BF16 unless a model-specific guide says otherwise. -### MoT Diffusion Model (SenseNova-U1) - -| Model | HF models | Scope | Status | Notes | -|-------|-----------|-------|--------|-------| -| SenseNova-U1 | `SenseNova/SenseNova-U1-8B-MoT` | Gen-path only (`*_mot_gen` layers) | Validated | Understanding-path layers stay in BF16; no `ignored_layers` needed | - -SenseNova-U1 uses Mixture-of-Tokenizers (MoT) with duplicate weights per -layer: understanding path (`qkv_proj`, `mlp`) and generation path -(`qkv_proj_mot_gen`, `mlp_mot_gen`). When `quantization="fp8"` is set, -the pipeline automatically applies FP8 only to gen-path GEMMs. - ### Multi-Stage Diffusion Model (BAGEL, GLM-Image) | Model | Scope | Status | Notes | diff --git a/tests/diffusion/quantization/test_quantization_fp8.py b/tests/diffusion/quantization/test_quantization_fp8.py index 8ff16e1f1..58f91742d 100644 --- a/tests/diffusion/quantization/test_quantization_fp8.py +++ b/tests/diffusion/quantization/test_quantization_fp8.py @@ -6,13 +6,11 @@ End-to-end tests for the unified quantization framework (PR #1764). Validates FP8 quantization works correctly for all supported model types: - Single-stage diffusion models (FLUX.1-dev, Qwen-Image, Z-Image-Turbo) - Multi-stage models (BAGEL: LLM + Diffusion) - - MoT gen-only models (SenseNova-U1: gen-path FP8, und-path BF16) Tests verify: 1. FP8 quantization produces valid images 2. Memory usage is lower than BF16 baseline 3. Multi-stage models only quantize the diffusion stage (not the LLM stage) - 4. MoT models only quantize gen-path layers (mot_gen) Usage: # Run all FP8 quantization tests @@ -438,92 +436,3 @@ def test_single_stage_quantization_config_key(): quantization_config="fp8", ) assert len(images) >= 1 - - -# ─── SenseNova-U1 gen-only FP8 tests ───────────────────────────────────────── - -_SENSENOVA_MODEL = "SenseNova/SenseNova-U1-8B-MoT" - - -def _generate_sensenova_u1_image( - quantization_config: str | None = None, - num_inference_steps: int = 20, -) -> tuple[Any, float]: - """Generate an image with SenseNova-U1 (single-stage MoT, gen-only FP8). - - Returns (generated_image, peak_memory_gib). - """ - omni_kwargs: dict[str, Any] = {} - if quantization_config: - omni_kwargs["quantization_config"] = quantization_config - - with OmniRunner(_SENSENOVA_MODEL, **omni_kwargs) as runner: - torch.accelerator.reset_peak_memory_stats() - - sampling_params = OmniDiffusionSamplingParams( - height=1024, - width=1024, - seed=42, - num_inference_steps=num_inference_steps, - extra_args={ - "cfg_scale": 4.0, - "cfg_norm": "none", - "timestep_shift": 3.0, - }, - ) - omni_outputs = list( - runner.omni.generate( - prompts={"prompt": "A cat sitting on a windowsill", "modalities": ["image"]}, - sampling_params_list=sampling_params, - ) - ) - - peak_mem = torch.accelerator.max_memory_allocated() / (1024**3) - - generated_image = None - for req_output in omni_outputs: - if images := getattr(req_output, "images", None): - generated_image = images[0] - break - - assert generated_image is not None, "No images generated from SenseNova-U1" - assert generated_image.size == (1024, 1024), f"Expected 1024x1024, got {generated_image.size}" - - return generated_image, peak_mem - - -@hardware_test(res={"cuda": "H100"}) -def test_sensenova_u1_fp8_generates_image(): - """SenseNova-U1 with gen-only FP8 generates a valid image. - - FP8 is applied only to gen-path (mot_gen) layers; understanding-path - layers stay in BF16. - """ - image, _ = _generate_sensenova_u1_image(quantization_config="fp8") - image.save("test_sensenova_u1_fp8.png") - - -@hardware_test(res={"cuda": "H100"}) -def test_sensenova_u1_bf16_generates_image(): - """SenseNova-U1 without quantization generates a valid image (baseline).""" - image, _ = _generate_sensenova_u1_image(quantization_config=None) - image.save("test_sensenova_u1_bf16.png") - - -@hardware_test(res={"cuda": "H100"}) -def test_sensenova_u1_fp8_uses_less_memory(): - """Gen-only FP8 should use less peak memory than BF16 for SenseNova-U1.""" - _, mem_bf16 = _generate_sensenova_u1_image( - quantization_config=None, - num_inference_steps=10, - ) - torch.accelerator.empty_cache() - - _, mem_fp8 = _generate_sensenova_u1_image( - quantization_config="fp8", - num_inference_steps=10, - ) - - print(f"SenseNova-U1 BF16 peak memory: {mem_bf16:.2f} GiB") - print(f"SenseNova-U1 FP8 peak memory: {mem_fp8:.2f} GiB") - assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)" diff --git a/tests/diffusion/quantization/test_sensenova_u1_fp8.py b/tests/diffusion/quantization/test_sensenova_u1_fp8.py deleted file mode 100644 index 4ed30e9a6..000000000 --- a/tests/diffusion/quantization/test_sensenova_u1_fp8.py +++ /dev/null @@ -1,175 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""CPU tests for SenseNova-U1 gen-only FP8 quantization. - -Validates that the ``_GenOnlyQuantConfig`` wrapper correctly routes FP8 -quantization to only the gen-path (``*_mot_gen``) layers while leaving -understanding-path layers in BF16. -""" - -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest -import torch -from vllm.distributed import parallel_state -from vllm.model_executor.layers.linear import LinearBase - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -@pytest.fixture(autouse=True) -def _fake_tp_group(): - """Provide a fake TP group so vLLM parallel layers can be instantiated. - - Note: a shared ``init_fake_tp_group`` fixture exists in - ``tests/helpers/fixtures/env.py`` but requires pytest-mock. - """ - mock_tp = MagicMock() - mock_tp.world_size = 1 - mock_tp.rank_in_group = 0 - old = parallel_state._TP - parallel_state._TP = mock_tp - yield - parallel_state._TP = old - - -def _make_minimal_llm_config(num_layers: int = 2) -> SimpleNamespace: - """Create a minimal SenseNova-U1 LLM config for unit testing.""" - return SimpleNamespace( - hidden_size=256, - intermediate_size=512, - num_hidden_layers=num_layers, - num_attention_heads=4, - num_key_value_heads=4, - head_dim=64, - hidden_act="silu", - rms_norm_eps=1e-6, - vocab_size=1024, - rope_theta=1000000.0, - rope_theta_hw=10000.0, - max_position_embeddings=4096, - max_position_embeddings_hw=1024, - attention_bias=True, - layer_types=["full_attention"] * num_layers, - tie_word_embeddings=False, - ) - - -def _build_model_with_gen_only_quant(quant_method: str | None = "fp8"): - """Construct SenseNovaU1ForCausalLM with gen-only FP8 wrapping.""" - from unittest.mock import MagicMock, patch - - from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import ( - _GenOnlyQuantConfig, - ) - from vllm_omni.diffusion.models.sensenova_u1.sensenova_u1_transformer import ( - SenseNovaU1ForCausalLM, - ) - from vllm_omni.quantization import build_quant_config - - inner_config = build_quant_config(quant_method) - if inner_config is not None: - quant_config = _GenOnlyQuantConfig(inner_config) - else: - quant_config = None - - llm_cfg = _make_minimal_llm_config(num_layers=2) - - mock_vllm_config = MagicMock() - mock_vllm_config.model_config.dtype = torch.bfloat16 - mock_vllm_config.model_config.quantization = quant_method - - with ( - torch.device("meta"), - patch("vllm.model_executor.layers.quantization.fp8.get_current_vllm_config", return_value=mock_vllm_config), - ): - model = SenseNovaU1ForCausalLM( - llm_cfg, - quant_config=quant_config, - prefix="language_model", - ) - return model - - -def test_mot_gen_layers_have_fp8_quant_method(): - """Gen-path (mot_gen) Linear layers should have a real FP8 quant_method.""" - from vllm.model_executor.layers.linear import UnquantizedLinearMethod - - model = _build_model_with_gen_only_quant("fp8") - - mot_gen_linears = [ - (name, mod) for name, mod in model.named_modules() if isinstance(mod, LinearBase) and "mot_gen" in name - ] - - assert len(mot_gen_linears) > 0, "No mot_gen LinearBase modules found" - - for name, mod in mot_gen_linears: - assert mod.quant_method is not None, f"mot_gen layer {name!r} should have quant_method with FP8" - assert not isinstance(mod.quant_method, UnquantizedLinearMethod), ( - f"mot_gen layer {name!r} should NOT use UnquantizedLinearMethod" - ) - - -def test_und_layers_have_no_quant_method(): - """Understanding-path Linear layers should use UnquantizedLinearMethod (stay BF16).""" - from vllm.model_executor.layers.linear import UnquantizedLinearMethod - - model = _build_model_with_gen_only_quant("fp8") - - und_linears = [ - (name, mod) for name, mod in model.named_modules() if isinstance(mod, LinearBase) and "mot_gen" not in name - ] - - assert len(und_linears) > 0, "No und LinearBase modules found" - - for name, mod in und_linears: - assert isinstance(mod.quant_method, UnquantizedLinearMethod), ( - f"und layer {name!r} should use UnquantizedLinearMethod, got {type(mod.quant_method).__name__}" - ) - - -def test_no_quant_method_without_quantization(): - """Without quantization, all layers should use UnquantizedLinearMethod.""" - from vllm.model_executor.layers.linear import UnquantizedLinearMethod - - model = _build_model_with_gen_only_quant(None) - - for name, mod in model.named_modules(): - if isinstance(mod, LinearBase): - assert isinstance(mod.quant_method, UnquantizedLinearMethod), ( - f"LinearBase module {name!r} has quant_method=" - f"{type(mod.quant_method).__name__} " - "but no quantization was configured" - ) - - -def test_und_and_gen_layers_both_present(): - """Sanity check: the model has both und and gen layers.""" - model = _build_model_with_gen_only_quant(None) - - layer_names = [name for name, _ in model.named_modules()] - - und_qkv = [n for n in layer_names if "qkv_proj" in n and "mot_gen" not in n] - gen_qkv = [n for n in layer_names if "qkv_proj_mot_gen" in n] - und_mlp = [n for n in layer_names if ".mlp." in n and "mot_gen" not in n] - gen_mlp = [n for n in layer_names if "mlp_mot_gen" in n] - - assert len(und_qkv) > 0, "No und qkv_proj layers found" - assert len(gen_qkv) > 0, "No gen qkv_proj_mot_gen layers found" - assert len(und_mlp) > 0, "No und mlp layers found" - assert len(gen_mlp) > 0, "No gen mlp_mot_gen layers found" - - -def test_gen_only_wrapper_delegates_attributes(): - """_GenOnlyQuantConfig should delegate attribute access to the inner config.""" - from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import ( - _GenOnlyQuantConfig, - ) - from vllm_omni.quantization import build_quant_config - - inner = build_quant_config("fp8") - wrapper = _GenOnlyQuantConfig(inner) - - assert wrapper.get_name() == "fp8" - assert wrapper.activation_scheme == inner.activation_scheme 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 917cea13e..fe8695b07 100644 --- a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py +++ b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py @@ -31,7 +31,6 @@ import torchvision.transforms as T from PIL import Image from transformers import AutoTokenizer from vllm.logger import init_logger -from vllm.model_executor.layers.quantization import QuantizationConfig from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.utils import get_local_device @@ -484,39 +483,6 @@ def _optimized_scale(positive_flat, negative_flat): return dot / sq_norm -# --------------------------------------------------------------------------- -# Gen-only FP8 wrapper -# --------------------------------------------------------------------------- - - -class _GenOnlyQuantConfig: - """Wraps an inner QuantizationConfig so only ``*_mot_gen`` layers are quantized. - - SenseNova-U1 uses Mixture-of-Tokenizers with duplicate weights per layer: - understanding path (``qkv_proj``, ``mlp``) and generation path - (``qkv_proj_mot_gen``, ``mlp_mot_gen``). The RFC requires FP8 only on the - gen-path GEMMs during denoising; und-path layers stay in BF16. - - This is *not* a full ``QuantizationConfig`` subclass — it only needs to - satisfy the duck-typed interface that ``LinearBase.__init__`` uses: - ``get_quant_method(layer, prefix)``. The outer registry and weight-loader - still operate on the original config stored in ``od_config.quantization_config``. - """ - - def __init__(self, inner: QuantizationConfig): - self._inner = inner - - def get_quant_method(self, layer, prefix: str = ""): - from vllm.model_executor.layers.linear import UnquantizedLinearMethod - - if "mot_gen" in prefix: - return self._inner.get_quant_method(layer, prefix) - return UnquantizedLinearMethod() - - def __getattr__(self, name): - return getattr(self._inner, name) - - # --------------------------------------------------------------------------- # Pipeline # --------------------------------------------------------------------------- @@ -583,15 +549,9 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli self.img_context_token_id = self.tokenizer.convert_tokens_to_ids(IMG_CONTEXT_TOKEN) self.img_start_token_id = self.tokenizer.convert_tokens_to_ids(IMG_START_TOKEN) - # Language model (TP-aware). - # Wrap quant_config so only gen-path (mot_gen) layers are quantized; - # understanding-path layers stay in BF16. - quant_config = od_config.quantization_config - if quant_config is not None: - quant_config = _GenOnlyQuantConfig(quant_config) + # Language model (TP-aware) self.language_model = SenseNovaU1ForCausalLM( self.llm_cfg, - quant_config=quant_config, prefix="language_model", ) From 82e74ce972ce64fec366e2e417365d05d6448d76 Mon Sep 17 00:00:00 2001 From: Yuekai Zhang Date: Sat, 6 Jun 2026 23:59:12 +0800 Subject: [PATCH 14/75] [TTS][New Model] support bosonai/higgs-audio-v3-tts-4b (#4169) Signed-off-by: Yuekai Zhang Signed-off-by: Yueqian Lin Co-authored-by: Yueqian Lin --- .buildkite/test-ready.yml | 23 + .../text_to_speech/higgs_audio_v3/end2end.py | 204 ++++ .../text_to_speech/higgs_audio_v3/README.md | 40 + .../higgs_audio_v3/batch_speech_client.py | 115 +++ .../higgs_audio_v3/run_server.sh | 32 + recipes/BosonAI/Higgs-Audio-V3-TTS.md | 110 ++ .../e2e/online_serving/test_higgs_audio_v3.py | 388 +++++++ tests/unit/higgs_audio_v3/__init__.py | 0 .../higgs_audio_v3/test_higgs_audio_v3.py | 910 +++++++++++++++++ vllm_omni/config/pipeline_registry.py | 4 + vllm_omni/data_entry_keys.py | 2 + vllm_omni/deploy/higgs_multimodal_qwen3.yaml | 69 ++ .../entrypoints/openai/protocol/audio.py | 48 + .../entrypoints/openai/serving_speech.py | 94 ++ .../models/higgs_audio_v3/__init__.py | 9 + .../configuration_higgs_audio_v3.py | 182 ++++ .../higgs_audio_v3/higgs_audio_v3_code2wav.py | 524 ++++++++++ .../higgs_audio_v3/higgs_audio_v3_talker.py | 958 ++++++++++++++++++ .../higgs_audio_v3_tokenizer.py | 135 +++ .../models/higgs_audio_v3/pipeline.py | 60 ++ vllm_omni/model_executor/models/registry.py | 16 + .../stage_input_processors/higgs_audio_v3.py | 372 +++++++ 22 files changed, 4295 insertions(+) create mode 100644 examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py create mode 100644 examples/online_serving/text_to_speech/higgs_audio_v3/README.md create mode 100644 examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py create mode 100755 examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh create mode 100644 recipes/BosonAI/Higgs-Audio-V3-TTS.md create mode 100644 tests/e2e/online_serving/test_higgs_audio_v3.py create mode 100644 tests/unit/higgs_audio_v3/__init__.py create mode 100644 tests/unit/higgs_audio_v3/test_higgs_audio_v3.py create mode 100644 vllm_omni/deploy/higgs_multimodal_qwen3.yaml create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/__init__.py create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/configuration_higgs_audio_v3.py create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_code2wav.py create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_tokenizer.py create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/pipeline.py create mode 100644 vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index 417db45be..8db0dba97 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -399,6 +399,29 @@ steps: volumes: - "/fsx/hf_cache:/fsx/hf_cache" + - label: "TTS · Higgs-Audio-V3 Test" + commands: + - | + timeout 20m bash -c " + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_USE_DEEP_GEMM=0 + export VLLM_MOE_USE_DEEP_GEMM=0 + pytest -s -v tests/e2e/online_serving/test_higgs_audio_v3.py -m 'core_model' --run-level 'core_model' + " + agents: + queue: "gpu_4_queue" + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + shm-size: "8gb" + environment: + - "HF_HOME=/fsx/hf_cache" + - "HF_TOKEN" + volumes: + - "/fsx/hf_cache:/fsx/hf_cache" + - label: "Diffusion · Qwen Image Edit Test" commands: - | diff --git a/examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py b/examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py new file mode 100644 index 000000000..8b2f3c8a1 --- /dev/null +++ b/examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline higgs-audio v3 inference example. + +Runs Stage 0 (Qwen3 talker) + Stage 1 (HiggsAudio codec) end-to-end +through the vLLM-Omni engine without going through the HTTP server, and +saves a 24 kHz mono WAV per prompt. + +Example (plain TTS): + + python examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py \\ + --texts "Hello world." \\ + --output-dir results/higgs_v3_wavs + +Example (voice clone): + + python examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py \\ + --texts "Hello world." \\ + --ref-audio path/to/reference.wav \\ + --ref-text "Transcript of the reference clip." \\ + --output-dir results/higgs_v3_wavs +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0") +os.environ.setdefault("VLLM_MOE_USE_DEEP_GEMM", "0") + +import re +import time +from pathlib import Path + +import numpy as np +import soundfile as sf +import torch + +from vllm_omni import Omni + +SAMPLE_RATE = 24_000 +DEFAULT_TEXTS = ( + "Hello world.", + "The quick brown fox jumps over the lazy dog.", + "Today is a beautiful day for a walk in the park.", +) + + +def parse_args(): + import argparse + + parser = argparse.ArgumentParser(description="Offline higgs-audio v3 inference") + parser.add_argument( + "--model", + type=str, + default="bosonai/higgs-audio-v3-tts-4b", + help="Stage-0 talker model id or path.", + ) + parser.add_argument( + "--texts", + type=str, + nargs="+", + default=list(DEFAULT_TEXTS), + help="One or more plain-text prompts to synthesize.", + ) + parser.add_argument( + "--output-dir", + type=str, + default="results/higgs_v3_wavs", + help="Directory to write per-prompt WAV files.", + ) + parser.add_argument( + "--deploy-config", + type=str, + default=None, + help="Override the deploy config path.", + ) + parser.add_argument( + "--max-new-tokens", + type=int, + default=2048, + help="Cap on Stage-0 codec frames per request.", + ) + parser.add_argument( + "--ref-audio", + type=str, + default=None, + help="Reference audio for voice clone (WAV/FLAC/MP3 path).", + ) + parser.add_argument( + "--ref-text", + type=str, + default=None, + help="Transcript of the reference audio. Optional but improves fidelity.", + ) + return parser.parse_args() + + +def _slugify(text: str) -> str: + slug = re.sub(r"\s+", "_", text.strip().lower()) + slug = re.sub(r"[^a-z0-9_]+", "", slug) + return slug[:48] or "prompt" + + +def _extract_pcm(multimodal_output: dict) -> torch.Tensor: + audio = multimodal_output.get("model_outputs") + if audio is None: + audio = multimodal_output.get("audio") + if audio is None: + raise ValueError(f"no audio key in multimodal_output: {list(multimodal_output.keys())}") + if isinstance(audio, list): + valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None] + if not valid: + raise ValueError("audio list is empty") + return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0] + return torch.as_tensor(audio).float().cpu().reshape(-1) + + +def _pcm_to_int16(pcm: torch.Tensor) -> np.ndarray: + arr = pcm.numpy() + if arr.dtype.kind == "f": + arr = np.clip(arr, -1.0, 1.0) + arr = (arr * 32767.0).astype(np.int16) + else: + arr = arr.astype(np.int16) + return arr + + +def main(): + args = parse_args() + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + engine = Omni(model=args.model, deploy_config=args.deploy_config, trust_remote_code=True) + + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + apply_delay_pattern, + encode_reference_audio, + ) + + tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True) + adapter = HiggsAudioV3TokenizerAdapter(tokenizer) + + # Load and encode reference audio once (if voice cloning) + ref_codes_delayed: torch.Tensor | None = None + if args.ref_audio is not None: + ref_wav, ref_sr = sf.read(args.ref_audio, always_2d=False) + if ref_wav.ndim == 2: + ref_wav = ref_wav.mean(axis=1) + ref_codes_raw = encode_reference_audio(ref_wav, int(ref_sr)) + ref_codes_delayed = apply_delay_pattern(ref_codes_raw) + print(f"Reference : {args.ref_audio}") + print(f"Ref codes : {ref_codes_raw.shape[0]} frames -> {ref_codes_delayed.shape[0]} delayed") + if args.ref_text: + print(f"Ref text : {args.ref_text}") + + print(f"Model : {args.model}") + print(f"Prompts : {len(args.texts)}") + print(f"Output dir : {output_dir}") + print(f"Voice clone : {'yes' if ref_codes_delayed is not None else 'no'}") + + total_elapsed = 0.0 + total_dur = 0.0 + for text in args.texts: + if ref_codes_delayed is not None: + prompt_ids = adapter.build_prompt( + text, + num_ref_tokens=int(ref_codes_delayed.shape[0]), + reference_text=args.ref_text, + ) + prompt = { + "prompt_token_ids": prompt_ids, + "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), + }, + } + else: + prompt_ids = adapter.build_prompt(text) + prompt = {"prompt_token_ids": prompt_ids} + + t_start = time.perf_counter() + outputs = engine.generate([prompt]) + elapsed = time.perf_counter() - t_start + total_elapsed += elapsed + + mm = outputs[0].outputs[0].multimodal_output + pcm = _extract_pcm(mm) + slug = _slugify(text) + out_path = output_dir / f"{slug}.wav" + sf.write(str(out_path), _pcm_to_int16(pcm), SAMPLE_RATE, format="WAV", subtype="PCM_16") + dur = pcm.numel() / SAMPLE_RATE + total_dur += dur + print(f" {slug:<50} dur={dur:5.2f}s -> {out_path}") + + rtf = total_elapsed / total_dur if total_dur > 0 else float("inf") + print(f"Total infer : {total_elapsed:.2f}s total audio: {total_dur:.2f}s RTF: {rtf:.3f}") + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/text_to_speech/higgs_audio_v3/README.md b/examples/online_serving/text_to_speech/higgs_audio_v3/README.md new file mode 100644 index 000000000..d1ca8f7b7 --- /dev/null +++ b/examples/online_serving/text_to_speech/higgs_audio_v3/README.md @@ -0,0 +1,40 @@ +# Higgs-Audio V3 Online Serving + +## Start the server + +```bash +# Default: GPU 0, port 8095 +./examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh + +# Custom GPU / port +PORT=8096 GPUS=0,1 ./examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh +``` + +## Plain text TTS + +```bash +python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py \ + --base-url http://localhost:8095 \ + --output-dir /tmp/higgs_v3_batch \ + --prompts "Hello world." "The quick brown fox jumps over the lazy dog." +``` + +## Voice clone + +```bash +python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py \ + --base-url http://localhost:8095 \ + --output-dir /tmp/higgs_v3_clone \ + --ref-audio path/to/reference.wav \ + --ref-text "transcript of the reference clip" \ + --prompts "Text to synthesize in the cloned voice." +``` + +## curl example + +```bash +curl -X POST http://localhost:8095/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{"model": "higgs_audio_v3", "input": "Hello world."}' \ + --output hello.wav +``` diff --git a/examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py b/examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py new file mode 100644 index 000000000..0941ba7dc --- /dev/null +++ b/examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Batch client for the higgs-audio v3 online server. + +Sends prompts to ``/v1/audio/speech`` and saves the returned WAV files. + +Usage (plain text -> speech): + + python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py \ + --base-url http://localhost:8095 \ + --output-dir /tmp/higgs_v3_batch \ + --prompts "Hello world." "The quick brown fox jumps over the lazy dog." + +Usage (voice clone): + + python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py \ + --base-url http://localhost:8095 \ + --output-dir /tmp/higgs_v3_clone \ + --ref-audio path/to/reference.wav \ + --ref-text "the transcript of the reference clip" \ + --prompts "Hello world." +""" + +from __future__ import annotations + +import argparse +import base64 +import sys +from pathlib import Path + +DEFAULT_PROMPTS = ( + "Hello world.", + "The quick brown fox jumps over the lazy dog.", + "Today is a beautiful day for a walk in the park.", + "Innovation distinguishes between a leader and a follower.", +) + + +def _slug(text: str) -> str: + import re + + s = re.sub(r"\s+", "_", text.strip().lower()) + return re.sub(r"[^a-z0-9_]+", "", s)[:32] or "prompt" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base-url", default="http://localhost:8095") + parser.add_argument("--model", default="higgs_audio_v3") + parser.add_argument("--prompts", nargs="+", default=list(DEFAULT_PROMPTS)) + parser.add_argument("--output-dir", type=Path, default=Path("/tmp/higgs_v3_batch")) + parser.add_argument("--format", choices=("wav", "pcm"), default="wav") + parser.add_argument("--max-new-tokens", type=int, default=2048) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--timeout-s", type=float, default=120.0) + parser.add_argument( + "--ref-audio", + type=Path, + default=None, + help="Reference clip for voice clone (WAV/FLAC/MP3 path). Pair with --ref-text.", + ) + parser.add_argument( + "--ref-text", + type=str, + default=None, + help="Transcript of the reference clip. Optional but improves fidelity.", + ) + args = parser.parse_args() + + ref_audio_data_url: str | None = None + if args.ref_audio is not None: + if not args.ref_audio.exists(): + print(f"ref-audio file not found: {args.ref_audio}", file=sys.stderr) + return 2 + mime = "audio/wav" if args.ref_audio.suffix.lower() == ".wav" else "audio/mpeg" + ref_b64 = base64.b64encode(args.ref_audio.read_bytes()).decode("ascii") + ref_audio_data_url = f"data:{mime};base64,{ref_b64}" + + try: + import httpx + except ImportError: + print("this client needs `httpx`. Install with `pip install httpx`.", file=sys.stderr) + return 2 + + args.output_dir.mkdir(parents=True, exist_ok=True) + url = args.base_url.rstrip("/") + "/v1/audio/speech" + failures = 0 + with httpx.Client(timeout=args.timeout_s) as client: + for prompt in args.prompts: + payload = { + "model": args.model, + "input": prompt, + "response_format": args.format, + "max_new_tokens": args.max_new_tokens, + "seed": args.seed, + } + if ref_audio_data_url is not None: + payload["ref_audio"] = ref_audio_data_url + if args.ref_text: + payload["ref_text"] = args.ref_text + resp = client.post(url, json=payload) + if resp.status_code != 200: + print(f"[FAIL] {prompt!r} -> {resp.status_code}: {resp.text[:200]}", file=sys.stderr) + failures += 1 + continue + suffix = ".wav" if args.format == "wav" else ".pcm" + out = args.output_dir / f"{_slug(prompt)}{suffix}" + out.write_bytes(resp.content) + print(f"[ ok ] {prompt!r} -> {out} ({len(resp.content)} bytes)") + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh b/examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh new file mode 100755 index 000000000..0694a30e7 --- /dev/null +++ b/examples/online_serving/text_to_speech/higgs_audio_v3/run_server.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Launch vLLM-Omni server for higgs-audio v3. +# +# Supports plain text TTS and voice cloning via /v1/audio/speech. +# +# Usage: +# ./run_server.sh # default port 8095, GPU 0 +# PORT=8096 GPUS=0,1 ./run_server.sh +# MODEL=/path/to/local/checkpoint ./run_server.sh + +set -e + +MODEL="${MODEL:-bosonai/higgs-audio-v3-tts-4b}" +PORT="${PORT:-8095}" +GPUS="${GPUS:-0}" +GPU_UTIL="${GPU_UTIL:-0.6}" + +echo "Starting higgs-audio v3 server" +echo " MODEL=$MODEL" +echo " PORT=$PORT" +echo " CUDA_VISIBLE_DEVICES=$GPUS" + +CUDA_VISIBLE_DEVICES="$GPUS" \ +VLLM_USE_DEEP_GEMM=0 \ +VLLM_MOE_USE_DEEP_GEMM=0 \ +vllm-omni serve "$MODEL" \ + --deploy-config vllm_omni/deploy/higgs_multimodal_qwen3.yaml \ + --host 0.0.0.0 \ + --port "$PORT" \ + --gpu-memory-utilization "$GPU_UTIL" \ + --trust-remote-code \ + --omni diff --git a/recipes/BosonAI/Higgs-Audio-V3-TTS.md b/recipes/BosonAI/Higgs-Audio-V3-TTS.md new file mode 100644 index 000000000..07f252bef --- /dev/null +++ b/recipes/BosonAI/Higgs-Audio-V3-TTS.md @@ -0,0 +1,110 @@ +# Higgs-Audio V3 TTS + +> Multilingual text-to-speech with voice cloning on 1×H100 + +## Summary + +- Vendor: Boson AI +- Model: `bosonai/higgs-audio-v3-tts-4b` +- Task: Text-to-speech synthesis with optional voice cloning (100+ languages) +- Mode: Online serving with the OpenAI-compatible `/v1/audio/speech` API; offline batch inference +- Maintainer: @yuekaiz + +## When to use this recipe + +Use this recipe to serve `bosonai/higgs-audio-v3-tts-4b` for high-quality +multilingual TTS. The model generates 24 kHz speech, supports zero-shot voice +cloning from a reference clip, and handles 100+ languages with inline control +tokens for emotion, style, and prosody. The architecture is a ~4B Qwen3 +backbone with fused multi-codebook embedding/head (8 codebooks × 1026 vocab, +MusicGen-style delay pattern). + +## References + +- Model card: [bosonai/higgs-audio-v3-tts-4b](https://huggingface.co/bosonai/higgs-audio-v3-tts-4b) +- Offline example: [`examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py`](../../examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py) +- Online example: [`examples/online_serving/text_to_speech/higgs_audio_v3/`](../../examples/online_serving/text_to_speech/higgs_audio_v3/) +- Benchmark results: see Performance section below + +## Hardware Support + +## GPU + +### 1×H100 80GB + +#### Environment + +- OS: Linux +- Python: 3.12+ +- CUDA: 12.x +- vLLM version: 0.22.0 +- vLLM-Omni version or commit: `36e048fd` (branch `higgs-v3`) + +#### Command + +**Online serving:** + +```bash +vllm-omni serve bosonai/higgs-audio-v3-tts-4b \ + --host 0.0.0.0 --port 8095 \ + --trust-remote-code --omni +``` + +The default deploy config `vllm_omni/deploy/higgs_multimodal_qwen3.yaml` is +loaded automatically by model registry (HF `model_type=higgs_multimodal_qwen3`). + +**Offline batch inference:** + +```bash +python examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py \ + --texts "Hello world." "The quick brown fox jumps over the lazy dog." \ + --output-dir results/higgs_v3_wavs +``` + +**Offline voice clone:** + +```bash +python examples/offline_inference/text_to_speech/higgs_audio_v3/end2end.py \ + --texts "Text to synthesize in the cloned voice." \ + --ref-audio path/to/reference.wav \ + --ref-text "Transcript of the reference clip." \ + --output-dir results/higgs_v3_clone +``` + +#### Verification + +Basic TTS via curl: + +```bash +curl -X POST http://localhost:8095/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "model": "bosonai/higgs-audio-v3-tts-4b", + "input": "Hello, how are you?" + }' --output hello.wav +``` + +Voice clone via Python client: + +```bash +python examples/online_serving/text_to_speech/higgs_audio_v3/batch_speech_client.py \ + --base-url http://localhost:8095 \ + --model bosonai/higgs-audio-v3-tts-4b \ + --ref-audio path/to/reference.wav \ + --ref-text "Transcript of the reference." \ + --prompts "Text to clone." +``` + +#### Notes + +- Memory usage: Stage 0 (talker, ~4B) uses ~60% GPU memory; Stage 1 (codec decoder) uses ~25%. +- Key flags: `--trust-remote-code` and `--omni` are required. +- Output: 24 kHz mono WAV. +- Voice cloning: `ref_audio` accepts WAV/FLAC/MP3; `ref_text` is optional but improves fidelity. +- Deploy config: `vllm_omni/deploy/higgs_multimodal_qwen3.yaml` (auto-discovered from `model_type`). + - `max_num_seqs=16` for both stages. + - `enforce_eager=true` for both stages (CUDA graph available for stage 0 but no throughput gain at batch>1). +- Known limitations: + - CUDA graph for stage 0 helps batch=1 (-13% RTF) but not batch>1 due to model-owned sampler running outside graph. + - Stage 1 (code2wav) must use `enforce_eager=true` (`@torch.inference_mode` incompatible with graph capture). + - Async streaming (chunk-based) not yet implemented; sync-only pipeline. diff --git a/tests/e2e/online_serving/test_higgs_audio_v3.py b/tests/e2e/online_serving/test_higgs_audio_v3.py new file mode 100644 index 000000000..aaa5498b3 --- /dev/null +++ b/tests/e2e/online_serving/test_higgs_audio_v3.py @@ -0,0 +1,388 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""End-to-end online tests for higgs-audio v3 against /v1/audio/speech. + +Mirrors the higgs_audio_v2 test layout. Covers the plain-text-in / audio-out +happy path, a small concurrent burst (Stage 0 prefix caching has to coexist +with batching), the voice-clone path via ``ref_audio`` + ``ref_text``, the +upstream ``references[]`` cookbook alias added by +``normalize_references_alias`` in ``protocol/audio.py``, and the few +validator rejections that should remain 4xx. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") +os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0") +os.environ.setdefault("VLLM_MOE_USE_DEEP_GEMM", "0") + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import load_test_audio_data_url +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = "bosonai/higgs-audio-v3-tts-4b" +STAGE_CONFIG = get_deploy_config_path("higgs_multimodal_qwen3.yaml") +SERVER_ARGS = ["--trust-remote-code", "--disable-log-stats"] +SERVER_ENV = {"VLLM_USE_DEEP_GEMM": "0", "VLLM_MOE_USE_DEEP_GEMM": "0"} + +TEST_PARAMS = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=STAGE_CONFIG, + server_args=SERVER_ARGS, + env_dict=SERVER_ENV, + ), + id="higgs_audio_v3_plain_text", + ) +] + +DEFAULT_SPEECH_TIMEOUT_S = 180.0 +# Floor for ~0.5 s of 24 kHz mono PCM_16: 24000 * 0.5 * 2 bytes ~= 24 KiB. +# A WAV header adds 44 bytes; conservative floor catches truncated / +# silence-only outputs without flagging short legitimate clips. +_MIN_AUDIO_BYTES = 20_000 + +# Reuse the shared TTS reference clip (clean ~5 s 24 kHz mono human speech) +# vendored under tests/assets/qwen3_tts/. Keeps a single WAV across TTS +# tests rather than duplicating asset bytes. +_REF_AUDIO_URL = load_test_audio_data_url("qwen3_tts/clone_2.wav") +_REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you." + + +@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) +class TestHiggsAudioV3OnlineHappyPath: + """Plain-text -> audio happy paths against the live HTTP server.""" + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_plain_text_wav(self, omni_server, openai_client) -> None: + """Single non-streaming WAV request - canonical TTS happy path.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "Hello world.", + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_plain_text_with_max_new_tokens(self, omni_server, openai_client) -> None: + """``max_new_tokens`` is one of the few extra fields the v3 validator accepts.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "Innovation distinguishes between a leader and a follower.", + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "max_new_tokens": 500, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_concurrent_plain_text(self, omni_server, openai_client) -> None: + """Three concurrent non-streaming requests - guards per-slot audio state and Stage-0 PC under batching.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "It was the night before my birthday.", + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + }, + request_num=3, + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_plain_text_pcm_streaming(self, omni_server, openai_client) -> None: + """Streaming PCM via talker2code2wav_async_chunk + the connector's codec_streaming path. + + The deploy YAML pins ``async_chunk: true`` and ``codec_streaming: true`` so chunks + flow Stage 0 -> Stage 1 per AR loop. The Stage-1 codec honors + ``meta.left_context_size`` and ``meta.right_holdback_size`` so per-chunk codec + windows stitch into a coherent PCM stream. The byte-count gate is the same as + the sync paths; per-chunk audio content is verified offline against Whisper. + + NOTE: ``min_hnr_db=0.0`` sits below the typical speech-noise floor so the + check still catches catastrophic codec failure (silence, white noise, sample + scramble all give HNR << 0) while allowing for the sliding-window codec's + slightly-noisier-than-sync output. The default 1.0 dB threshold has only + ~0.16 dB margin over measured single-request output (1.16 dB on L4), so + flake risk is too high there. + """ + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "The quick brown fox jumps over the lazy dog.", + "stream": True, + "response_format": "pcm", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + "min_hnr_db": 0.0, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_concurrent_pcm_streaming(self, omni_server, openai_client) -> None: + """Three concurrent streaming requests - guards per-request frame cursors + in ``talker2code2wav_async_chunk`` and per-slot delay-pattern state under batched AR. + + NOTE: ``min_hnr_db=-2.0`` is looser than the single-request streaming test + because batched AR adds per-request codec quality variance (DAC boundary + artifacts compounded across the 3-way batch); -2.0 dB gives ~1.6 dB margin + over the measured worst-of-3 on L4 (-0.42 dB) while still well above the + catastrophic-failure region. + """ + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "She sells seashells by the seashore.", + "stream": True, + "response_format": "pcm", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + "min_hnr_db": -2.0, + }, + request_num=3, + ) + + +@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) +class TestHiggsAudioV3OnlineInlineControlTokens: + """Inline control-token surface from the bosonai cookbook. + + The model exposes four categories of ``<|category:value|>`` tokens + (emotion, style, prosody, sfx). Delivery tokens (emotion / style / + prosody speed-pitch-expressive) go at the START of input; positional + tokens (pause, sfx) go inline. SFX tokens must pair with their written + onomatopoeia. + + These tests check the *serving surface* - the validator accepts the + payload, the engine produces audio, and the WAV has non-trivial size. + They do not assert audio quality (out of scope for CI). + """ + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_inline_emotion_and_expressive(self, omni_server, openai_client) -> None: + """Delivery tokens (emotion + expressive_high) at the start of input.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": ("<|emotion:amusement|><|prosody:expressive_high|>Wait, that was actually hilarious."), + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_inline_style_whispering(self, omni_server, openai_client) -> None: + """Style token at the start - ``whispering``.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "<|style:whispering|>It is just between you and me, alright?", + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_inline_prosody_speed_and_pitch(self, omni_server, openai_client) -> None: + """Two prosody tokens at the start - slow speed plus low pitch.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": ( + "<|prosody:speed_slow|><|prosody:pitch_low|>The radar shows a storm approaching from the east." + ), + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_inline_pause_mid_text(self, omni_server, openai_client) -> None: + """Positional pause token placed inline between two clauses.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "Hold on a moment <|prosody:pause|> let me think about it.", + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_inline_sfx_with_onomatopoeia(self, omni_server, openai_client) -> None: + """SFX token paired with its written onomatopoeia (``<|sfx:laughter|>Hehe``).""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": ( + "<|emotion:amusement|>I cannot believe that just happened. " + "<|sfx:laughter|>Hehe, I am still recovering from it." + ), + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + +@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) +class TestHiggsAudioV3OnlineVoiceClone: + """Voice clone via the two payload shapes the model serves.""" + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_voice_clone_ref_audio_ref_text(self, omni_server, openai_client) -> None: + """Canonical vllm-omni voice clone via ``ref_audio`` + ``ref_text``.""" + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "Hello world.", + "ref_audio": _REF_AUDIO_URL, + "ref_text": _REF_TEXT, + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_voice_clone_references_alias(self, omni_server, openai_client) -> None: + """BosonAI cookbook payload: ``references=[{audio_path, text}]``. + + ``normalize_references_alias`` (``protocol/audio.py``) is supposed to + translate the cookbook field into ``ref_audio`` / ``ref_text``; without + it the request would silently fall through to zero-shot synthesis. + """ + openai_client.send_audio_speech_request( + { + "model": omni_server.model, + "input": "Hello world.", + "references": [{"audio_path": _REF_AUDIO_URL, "text": _REF_TEXT}], + "stream": False, + "response_format": "wav", + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + ) + + +@pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) +class TestHiggsAudioV3OnlineValidatorRejections: + """Out-of-scope shapes must come back as 4xx. + + These cases use ``send_audio_speech_http_request`` (raw HTTP POST against + ``/v1/audio/speech``) rather than the OpenAI SDK helper because: + + - We need the failure-path matcher; the SDK helper asserts success. + - The cookbook ``references`` field is not in the OpenAI SDK schema, so + the SDK strips it before the request leaves the client and the server + sees a plain TTS request that returns 200. Raw HTTP keeps the JSON body + intact so the validator actually fires. + """ + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_rejects_empty_input(self, omni_server, openai_client) -> None: + """Empty text input - validator should reject before reaching the engine.""" + openai_client.send_audio_speech_http_request( + { + "json": { + "model": omni_server.model, + "input": "", + "response_format": "wav", + }, + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "err_code": (400, 422), + "err_message": ("input", "empty"), + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_rejects_multi_reference_payload(self, omni_server, openai_client) -> None: + """``references[]`` with more than one entry - multi-shot voice clone is not supported.""" + openai_client.send_audio_speech_http_request( + { + "json": { + "model": omni_server.model, + "input": "Hello world.", + "references": [ + {"audio_path": _REF_AUDIO_URL, "text": _REF_TEXT}, + {"audio_path": _REF_AUDIO_URL, "text": _REF_TEXT}, + ], + "response_format": "wav", + }, + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "err_code": (400, 422), + "err_message": "references", + } + ) + + @pytest.mark.core_model + @pytest.mark.tts + @hardware_test(res={"cuda": "H100"}, num_cards=1) + def test_rejects_conflicting_ref_audio_and_references(self, omni_server, openai_client) -> None: + """``ref_audio`` and ``references`` cannot both be set to different values.""" + openai_client.send_audio_speech_http_request( + { + "json": { + "model": omni_server.model, + "input": "Hello world.", + "ref_audio": _REF_AUDIO_URL, + "references": [{"audio_path": "https://example.com/other.wav"}], + "response_format": "wav", + }, + "timeout": DEFAULT_SPEECH_TIMEOUT_S, + "err_code": (400, 422), + "err_message": "mutually exclusive", + } + ) diff --git a/tests/unit/higgs_audio_v3/__init__.py b/tests/unit/higgs_audio_v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py new file mode 100644 index 000000000..d73f0c6a5 --- /dev/null +++ b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py @@ -0,0 +1,910 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Deterministic unit tests for higgs-audio v3. + +These tests verify AC-1 (config), AC-3 (prompt), AC-4 (fused modules), +AC-5 (delay pattern), AC-7 (stage processor), and AC-10 (registry) +without requiring the actual checkpoint or GPU. +""" + +import pytest +import torch + +# ---- AC-1: Configuration ---- + + +class TestHiggsAudioV3Config: + def test_default_config_loads(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + config = HiggsAudioV3Config() + assert config.num_codebooks == 8 + assert config.codebook_size == 1026 + assert config.audio_stream_bos_id == 1024 + assert config.audio_stream_eos_id == 1025 + assert config.sample_rate == 24000 + assert config.frame_rate == 25 + assert config.num_real_codes == 1024 + assert config.tie_modality_embeddings is True + + def test_custom_audio_encoder_config(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + config = HiggsAudioV3Config( + audio_encoder_config={ + "encoder_type": "discrete", + "num_codebooks": 4, + "vocab_size": 512, + "tie_word_embeddings": False, + } + ) + assert config.num_codebooks == 4 + assert config.codebook_size == 512 + assert config.tie_modality_embeddings is False + + def test_invalid_num_codebooks_rejected(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + with pytest.raises(ValueError, match="num_codebooks must be > 0"): + HiggsAudioV3Config(audio_encoder_config={"num_codebooks": 0, "vocab_size": 1026}) + + def test_negative_num_codebooks_rejected(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + with pytest.raises(ValueError, match="num_codebooks must be > 0"): + HiggsAudioV3Config(audio_encoder_config={"num_codebooks": -1, "vocab_size": 1026}) + + def test_special_token_ids_initially_none(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + config = HiggsAudioV3Config() + assert config.tts_token_id is None + assert config.text_token_id is None + assert config.audio_continuation_id is None + + def test_hidden_size_from_text_config(self): + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + + config = HiggsAudioV3Config(text_config={"model_type": "qwen3", "hidden_size": 2560}) + assert config.hidden_size == 2560 + assert config.audio_hidden_size == 2560 + + +# ---- AC-4: Fused Multi-Codebook Modules ---- + + +class TestFusedModules: + def test_embedding_shape(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsFusedMultiTextEmbedding, + ) + + embed = HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=256) + assert embed.weight.shape == (8 * 1026, 256) + + codes = torch.randint(0, 1024, (5, 8)) + out = embed(codes) + assert out.shape == (5, 256) + + def test_embedding_offset_indexing(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsFusedMultiTextEmbedding, + ) + + embed = HiggsFusedMultiTextEmbedding(num_codebooks=2, vocab_size=4, hidden_size=3) + # Set weights to identity-like pattern so we can verify offsets + with torch.no_grad(): + embed.weight.zero_() + # Codebook 0, vocab [0..3] -> row 0..3 + # Codebook 1, vocab [0..3] -> row 4..7 + for i in range(8): + embed.weight[i, i % 3] = float(i) + + # codes[0] = [1, 2] -> embed[1] + embed[4+2=6] + codes = torch.tensor([[1, 2]]) + out = embed(codes) + expected = embed.weight[1] + embed.weight[6] + assert torch.allclose(out[0], expected) + + def test_head_shape(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsFusedMultiTextHead, + ) + + head = HiggsFusedMultiTextHead(num_codebooks=8, vocab_size=1026, hidden_size=256) + assert head.weight.shape == (8 * 1026, 256) + + hidden = torch.randn(3, 256) + out = head.generate(hidden) + assert out.shape == (3, 8, 1026) + + def test_tying(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsFusedMultiTextEmbedding, + HiggsFusedMultiTextHead, + ) + + embed = HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=64) + head = HiggsFusedMultiTextHead(num_codebooks=8, vocab_size=1026, hidden_size=64) + head.weight = embed.weight + assert head.weight is embed.weight + # Modification to one should reflect in the other + with torch.no_grad(): + embed.weight[0, 0] = 42.0 + assert head.weight[0, 0] == 42.0 + + def test_codes_out_of_range(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsFusedMultiTextEmbedding, + ) + + embed = HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=16) + # Code 1026 + offset for codebook 7 = 1026 + 7*1026 = 8208 + # But weight only has 8*1026 = 8208 rows, so index 8208 is out of bounds + codes = torch.tensor([[0, 0, 0, 0, 0, 0, 0, 1026]]) # Last cb: 1026 is OOB + with pytest.raises(IndexError): + embed(codes) + + +# ---- AC-5: Delay Pattern Behavior ---- + + +class TestDelayPatternBehavior: + """Test the delay pattern masking logic extracted from the talker.""" + + def test_boc_eoc_ids(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + BOC_ID, + EOC_ID, + ) + + assert BOC_ID == 1024 + assert EOC_ID == 1025 + + def test_delay_phase_boc_masking(self): + """During delay phase, codebooks beyond delay_count are forced to BOC.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID + + num_codebooks = 8 + # Simulate delay phase at step 3 (delay_count=2, so CBs 3-7 should be BOC) + codes = torch.randint(0, 1024, (num_codebooks,)) + delay_count = 2 + next_cb = delay_count + 1 + if next_cb < num_codebooks: + codes[next_cb:] = BOC_ID + # CBs 0-2 should have original codes, CBs 3-7 should be BOC + assert all(codes[i] != BOC_ID or i >= 3 for i in range(num_codebooks)) + assert all(codes[i] == BOC_ID for i in range(3, num_codebooks)) + + def test_cb0_eoc_triggers_rampdown(self): + """EOC on codebook 0 starts ramp-down; EOC on other codebooks does not.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + EOC_ID, + ) + + num_codebooks = 8 + codes = torch.randint(0, 1024, (num_codebooks,)) + + # Simulate: cb0 emits EOC + codes[0] = EOC_ID + assert int(codes[0].item()) == EOC_ID + # Ramp-down should start with countdown = N-2 = 6 + eoc_countdown = num_codebooks - 2 + assert eoc_countdown == 6 + + # If cb3 emits EOC but cb0 doesn't, no ramp-down + codes2 = torch.randint(0, 1024, (num_codebooks,)) + codes2[3] = EOC_ID + assert int(codes2[0].item()) != EOC_ID # cb0 is not EOC + + def test_rampdown_termination(self): + """After N-2 steps of ramp-down, generation_done becomes True.""" + num_codebooks = 8 + eoc_countdown = num_codebooks - 2 # = 6 + generation_done = False + for _ in range(6): + eoc_countdown -= 1 + if eoc_countdown <= 0: + generation_done = True + assert generation_done is True + + def test_all_boc_seed(self): + """First audio step should seed last_codes with all-BOC.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID + + seeded = torch.full((8,), BOC_ID, dtype=torch.long) + assert seeded.shape == (8,) + assert all(seeded[i] == BOC_ID for i in range(8)) + + +# ---- AC-5: Real Sampler Method Tests ---- + + +class TestSamplerMethods: + """Test the actual _sample_audio_codes and _apply_delay_pattern_masking methods.""" + + def _make_minimal_talker(self): + """Create a minimal talker-like object with sampler/masking methods.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + class FakeTalker: + num_codebooks = 8 + codebook_size = 1026 + _audio_state = {} + + t = FakeTalker() + t._sample_audio_codes = mod.HiggsAudioV3TalkerForConditionalGeneration._sample_audio_codes.__get__(t) + t._apply_delay_pattern_masking = ( + mod.HiggsAudioV3TalkerForConditionalGeneration._apply_delay_pattern_masking.__get__(t) + ) + return t + + def test_sample_respects_mask(self): + """Tokens masked to -inf must never be sampled.""" + t = self._make_minimal_talker() + logits = torch.full((10, 1026), float("-inf")) + # Only token 500 is allowed for each row + logits[:, 500] = 0.0 + result = t._sample_audio_codes(logits) + assert result.shape == (10,) + assert (result == 500).all(), f"Expected all 500, got {result.tolist()}" + + def test_sample_all_masked_falls_back_to_argmax(self): + """All-masked row should fall back to argmax (least-negative logit).""" + t = self._make_minimal_talker() + logits = torch.full((2, 1026), float("-inf")) + # Row 0: all masked + # Row 1: only token 42 allowed + logits[1, 42] = 0.0 + result = t._sample_audio_codes(logits) + assert result.shape == (2,) + assert result[1].item() == 42 + + def test_delay_masking_forces_boc_during_delay(self): + """During delay phase, codebooks beyond delay_count must have only BOC allowed.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID + + t = self._make_minimal_talker() + # Simulate delay_count=2 for batch row 0 + t._audio_state[0] = {"num_delay": 2, "num_remaining_delays": None} + cb_logits = torch.zeros(1, 8, 1026) # [1 audio row, 8 codebooks, 1026 vocab] + t._apply_delay_pattern_masking(cb_logits, [0]) + # CBs 3-7 should have only BOC allowed (everything else -inf) + for q in range(3, 8): + row = cb_logits[0, q] + assert row[BOC_ID].item() == 0.0 # BOC kept + # All non-BOC should be -inf + non_boc = torch.cat([row[:BOC_ID], row[BOC_ID + 1 :]]) + assert (non_boc == float("-inf")).all(), f"CB{q} has non-inf non-BOC values" + + def test_delay_masking_disallows_boc_for_active_codebooks(self): + """Active codebooks during delay should have BOC disallowed.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import BOC_ID + + t = self._make_minimal_talker() + t._audio_state[0] = {"num_delay": 3, "num_remaining_delays": None} + cb_logits = torch.zeros(1, 8, 1026) + t._apply_delay_pattern_masking(cb_logits, [0]) + # CBs 0-3 are active: BOC should be -inf + for q in range(4): + assert cb_logits[0, q, BOC_ID].item() == float("-inf") + + def test_delay_masking_only_cb0_allows_eoc(self): + """Only codebook 0 should allow EOC during normal generation.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + + t = self._make_minimal_talker() + t._audio_state[0] = {"num_delay": 8, "num_remaining_delays": None} + cb_logits = torch.zeros(1, 8, 1026) + t._apply_delay_pattern_masking(cb_logits, [0]) + # CB0 should keep EOC + assert cb_logits[0, 0, EOC_ID].item() != float("-inf") + # CB1-7 should have EOC masked + for q in range(1, 8): + assert cb_logits[0, q, EOC_ID].item() == float("-inf"), f"CB{q} EOC not masked" + + def test_rampdown_masking_locks_to_eoc(self): + """During ramp-down, locked codebooks should only allow EOC.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + BOC_ID, + EOC_ID, + ) + + t = self._make_minimal_talker() + # Ramp-down with 4 remaining delays: lock CBs 0-3 to EOC + t._audio_state[0] = {"num_delay": 8, "num_remaining_delays": 4} + cb_logits = torch.zeros(1, 8, 1026) + t._apply_delay_pattern_masking(cb_logits, [0]) + # CBs 0-3 locked: only EOC allowed + for q in range(4): + row = cb_logits[0, q] + assert row[EOC_ID].item() == 0.0 + non_eoc = torch.cat([row[:EOC_ID], row[EOC_ID + 1 :]]) + assert (non_eoc == float("-inf")).all(), f"Locked CB{q} has non-inf non-EOC" + # CBs 4-7 active: BOC and EOC disallowed + for q in range(4, 8): + assert cb_logits[0, q, BOC_ID].item() == float("-inf") + assert cb_logits[0, q, EOC_ID].item() == float("-inf") + + +# ---- AC-6: Feedback Method Tests ---- + + +class TestFeedbackMethods: + def test_postprocess_emits_audio_codes(self): + """postprocess() should return codes from _last_audio_codes.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + class FakeTalker: + _last_audio_codes = torch.tensor([[100, 200, 300, 400, 500, 600, 700, 800]]) + _postprocess_cursor = 0 + + t = FakeTalker() + t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t) + result = t.postprocess(torch.zeros(1, 64)) + assert "codes" in result + assert "audio" in result["codes"] + assert result["codes"]["audio"].shape == (1, 8) + + def test_postprocess_skips_negative_rows(self): + """postprocess() should skip rows with -1 (no audio).""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + class FakeTalker: + _last_audio_codes = torch.tensor([[-1, -1, -1, -1, -1, -1, -1, -1]]) + _postprocess_cursor = 0 + + t = FakeTalker() + t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t) + result = t.postprocess(torch.zeros(1, 64)) + assert result == {} + + def test_postprocess_advances_cursor(self): + """postprocess() should advance cursor by 1 per call.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + class FakeTalker: + _last_audio_codes = torch.tensor( + [[100, 200, 300, 400, 500, 600, 700, 800], [-1, -1, -1, -1, -1, -1, -1, -1]] + ) + _postprocess_cursor = 0 + + t = FakeTalker() + t.postprocess = mod.HiggsAudioV3TalkerForConditionalGeneration.postprocess.__get__(t) + r1 = t.postprocess(torch.zeros(1, 64)) + assert "codes" in r1 + r2 = t.postprocess(torch.zeros(1, 64)) + assert r2 == {} # Second row is all -1 + + +# ---- AC-6: Audio Feedback Method Tests ---- + + +class TestAudioFeedback: + """Test _apply_audio_feedback with minimal fake talker.""" + + def _make_feedback_talker(self): + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + embed = mod.HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=16) + torch.nn.init.ones_(embed.weight) + + class FakeTalker: + num_codebooks = 8 + codebook_size = 1026 + _audio_continuation_id = 99999 # fake audio token + _last_step_query_start_loc = None + _audio_state = {} + multimodal_embedding = embed + model = type("M", (), {"embed_tokens": lambda self, ids: torch.zeros(ids.shape[0], 16)})() + + t = FakeTalker() + t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t) + return t + + def test_text_positions_unchanged(self): + """Non-audio positions should not be modified.""" + t = self._make_feedback_talker() + # No audio state → all positions unchanged + input_ids = torch.tensor([1, 2, 3, 4]) + hidden = torch.randn(4, 16) + result = t._apply_audio_feedback(hidden, input_ids) + assert torch.equal(result, hidden) + + def test_audio_position_replaced_with_state(self): + """Audio position with state should have its embedding replaced.""" + t = self._make_feedback_talker() + audio_id = t._audio_continuation_id + t._audio_state[1] = {"last_codes": torch.zeros(8, dtype=torch.long)} + input_ids = torch.tensor([1, audio_id, 3]) + hidden = torch.zeros(3, 16) + result = t._apply_audio_feedback(hidden, input_ids) + # Position 1 (audio) should be non-zero (embedding of all-0 codes) + assert result[1].abs().sum() > 0 + # Positions 0 and 2 should remain zero + assert result[0].abs().sum() == 0 + assert result[2].abs().sum() == 0 + + def test_no_state_no_replacement(self): + """Audio position without state should not be replaced.""" + t = self._make_feedback_talker() + audio_id = t._audio_continuation_id + # No state for row 0 + input_ids = torch.tensor([audio_id]) + hidden = torch.zeros(1, 16) + result = t._apply_audio_feedback(hidden, input_ids) + assert torch.equal(result, hidden) + + +# ---- AC-8: Codec Strictness ---- + + +class TestCodecStrictness: + def test_bundled_missing_quantizer_key_raises(self): + """Bundled codec load must fail when a quantizer codebook key is missing.""" + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_code2wav import ( + HiggsAudioV3Code2Wav, + ) + + config = HiggsAudioV3Config() + c2w = HiggsAudioV3Code2Wav(config=config) + + # State with only 7 quantizers (missing quantizer.quantizers.7.*) + codec_state = {} + for i in range(7): + codec_state[f"quantizer.quantizers.{i}.codebook.embed"] = torch.randn(1024, 64) + codec_state[f"quantizer.quantizers.{i}.project_out.weight"] = torch.randn(1024, 64) + codec_state[f"quantizer.quantizers.{i}.project_out.bias"] = torch.randn(1024) + codec_state["fc2.weight"] = torch.randn(256, 1024) + codec_state["fc2.bias"] = torch.randn(256) + + with pytest.raises(KeyError, match="quantizer 7"): + c2w._load_from_bundled_state(codec_state, device=torch.device("cpu")) + + def test_bundled_missing_fc2_raises(self): + """Bundled codec load must fail when fc2 keys are missing.""" + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_code2wav import ( + HiggsAudioV3Code2Wav, + ) + + config = HiggsAudioV3Config() + c2w = HiggsAudioV3Code2Wav(config=config) + + codec_state = {} + for i in range(8): + codec_state[f"quantizer.quantizers.{i}.codebook.embed"] = torch.randn(1024, 64) + codec_state[f"quantizer.quantizers.{i}.project_out.weight"] = torch.randn(1024, 64) + codec_state[f"quantizer.quantizers.{i}.project_out.bias"] = torch.randn(1024) + # No fc2 keys + + with pytest.raises(KeyError, match="fc2"): + c2w._load_from_bundled_state(codec_state, device=torch.device("cpu")) + + def test_bundled_unknown_decoder_key_rejected(self, monkeypatch): + """Unknown decoder-side key should be rejected as unconsumed.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_code2wav as c2w_mod + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_code2wav import ( + HiggsAudioV3Code2Wav, + ) + + class FakeDAC(torch.nn.Module): + def __init__(self): + super().__init__() + self.dummy = torch.nn.Parameter(torch.zeros(1)) + + monkeypatch.setattr(c2w_mod, "build_boson_dac_decoder", lambda device: FakeDAC().to(device)) + monkeypatch.setattr(c2w_mod, "build_higgs_audio_acoustic_decoder", lambda cfg, device: FakeDAC().to(device)) + + config = HiggsAudioV3Config() + c2w = HiggsAudioV3Code2Wav(config=config) + + codec_state = _build_full_codec_state() + codec_state["unknown_decoder_module.weight"] = torch.randn(10) + + with pytest.raises(RuntimeError, match="unexpected decoder-side keys"): + c2w._load_from_bundled_state(codec_state, device=torch.device("cpu")) + + def test_bundled_encoder_side_keys_accepted(self, monkeypatch): + """Known encoder-side keys should not trigger unconsumed-key rejection.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_code2wav as c2w_mod + from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, + ) + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_code2wav import ( + HiggsAudioV3Code2Wav, + ) + + class FakeDAC(torch.nn.Module): + def __init__(self): + super().__init__() + self.dummy = torch.nn.Parameter(torch.zeros(1)) + + monkeypatch.setattr(c2w_mod, "build_boson_dac_decoder", lambda device: FakeDAC().to(device)) + monkeypatch.setattr(c2w_mod, "build_higgs_audio_acoustic_decoder", lambda cfg, device: FakeDAC().to(device)) + + config = HiggsAudioV3Config() + c2w = HiggsAudioV3Code2Wav(config=config) + + codec_state = _build_full_codec_state() + codec_state["acoustic_encoder.block.0.conv_t1.weight"] = torch.randn(10) + codec_state["semantic_model.encoder.layers.0.weight"] = torch.randn(10) + codec_state["fc.weight"] = torch.randn(10) + + # Should NOT raise — encoder-side keys are allowed + c2w._load_from_bundled_state(codec_state, device=torch.device("cpu")) + assert c2w._loaded + + +def _build_minimal_codec_state() -> dict[str, torch.Tensor]: + """Build a minimal codec state dict (quantizer + fc2, no DAC).""" + state: dict[str, torch.Tensor] = {} + for i in range(8): + state[f"quantizer.quantizers.{i}.codebook.embed"] = torch.randn(1024, 64) + state[f"quantizer.quantizers.{i}.project_out.weight"] = torch.randn(1024, 64) + state[f"quantizer.quantizers.{i}.project_out.bias"] = torch.randn(1024) + state["fc2.weight"] = torch.randn(256, 1024) + state["fc2.bias"] = torch.randn(256) + return state + + +def _build_full_codec_state() -> dict[str, torch.Tensor]: + """Build a full codec state dict including acoustic_decoder keys. + + The acoustic_decoder keys here match what a FakeDAC module expects + (just 'dummy'). Real DAC has many more keys. + """ + state = _build_minimal_codec_state() + state["acoustic_decoder.dummy"] = torch.zeros(1) + return state + + +# ---- AC-2: Loader Required-Key Validation ---- + + +class TestLoaderRequiredKeys: + """Test exact required-key validation in load_weights.""" + + def test_build_required_keys_count(self): + """Required key set should have 1 text_emb + 36*11 layers + 1 norm + 1 modality = 399.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsAudioV3TalkerForConditionalGeneration, + ) + + keys = HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(36) + assert len(keys) == 1 + 36 * 11 + 1 + 1 # text + layers + norm + modality = 399 + + def test_build_required_keys_contains_norm(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsAudioV3TalkerForConditionalGeneration, + ) + + keys = HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(2) + assert "body.norm.weight" in keys + + def test_build_required_keys_contains_layer_subkeys(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsAudioV3TalkerForConditionalGeneration, + ) + + keys = HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(2) + assert "body.layers.0.self_attn.k_proj.weight" in keys + assert "body.layers.1.mlp.gate_proj.weight" in keys + + def test_build_required_keys_contains_modality_embedding(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsAudioV3TalkerForConditionalGeneration, + ) + + keys = HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(1) + assert "tied.embedding.modality_embeddings.0.embedding.weight" in keys + + def test_build_required_keys_contains_text_embedding(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + HiggsAudioV3TalkerForConditionalGeneration, + ) + + keys = HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(1) + assert "tied.embedding.text_embedding.weight" in keys + + +class TestLoadWeightsEnforcement: + """Test load_weights() itself with synthetic key sets via a minimal fake talker.""" + + def _make_fake_talker_and_keys(self): + """Build a minimal talker-like object and the full 1-layer key set.""" + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + # Minimal fake talker with just enough to run load_weights + talker = object.__new__(mod.HiggsAudioV3TalkerForConditionalGeneration) + torch.nn.Module.__init__(talker) + + # Attach minimal attributes load_weights needs + talker.multimodal_embedding = mod.HiggsFusedMultiTextEmbedding(8, 1026, 16) + talker.modality_head = mod.HiggsFusedMultiTextHead(8, 1026, 16) + talker.tie_modality = True + talker.modality_head.weight = talker.multimodal_embedding.weight + + # Fake backbone config with 1 layer + class FakeConfig: + num_hidden_layers = 1 + tie_word_embeddings = True + + talker._backbone_config = FakeConfig() + + # Minimal model and lm_head so _BackboneWrapper can be constructed + talker.model = torch.nn.Module() + talker.lm_head = torch.nn.Module() + + # Fake vllm_config for resolve_special_tokens (no-op) + class FakeModelConfig: + model = None + + class FakeVllmConfig: + model_config = FakeModelConfig() + + talker.vllm_config = FakeVllmConfig() + talker.config = type("C", (), {"resolve_special_tokens": lambda self, p: None})() + talker._resolved_tokens = True + talker._audio_continuation_id = None + talker._eos_token_id = None + + # Build the full required key set for 1 layer + required = mod.HiggsAudioV3TalkerForConditionalGeneration._build_required_keys(1) + + # Build synthetic weights: tiny tensors for every required key + weights = {} + for key in required: + if "modality_embeddings.0.embedding" in key: + weights[key] = torch.randn(8 * 1026, 16) + else: + weights[key] = torch.randn(4) # tiny placeholder + + # Monkeypatch _BackboneWrapper.load_weights to accept and return keys + def fake_backbone_load(self_wrapper, ws): + consumed = set() + for name, _ in ws: + consumed.add(name) + return consumed + + talker._fake_backbone_load = fake_backbone_load + return talker, weights, mod, fake_backbone_load + + def test_load_weights_succeeds_with_all_keys(self, monkeypatch): + """load_weights succeeds when all required keys are present.""" + talker, weights, mod, fake_load = self._make_fake_talker_and_keys() + monkeypatch.setattr(mod._BackboneWrapper, "load_weights", fake_load) + result = talker.load_weights(iter(weights.items())) + assert isinstance(result, set) + assert "multimodal_embedding.weight" in result + + def test_load_weights_fails_missing_norm(self, monkeypatch): + """load_weights raises when body.norm.weight is missing.""" + talker, weights, mod, fake_load = self._make_fake_talker_and_keys() + monkeypatch.setattr(mod._BackboneWrapper, "load_weights", fake_load) + del weights["body.norm.weight"] + with pytest.raises(RuntimeError, match="body.norm.weight"): + talker.load_weights(iter(weights.items())) + + def test_load_weights_fails_missing_layer_subkey(self, monkeypatch): + """load_weights raises when a specific layer subkey is missing.""" + talker, weights, mod, fake_load = self._make_fake_talker_and_keys() + monkeypatch.setattr(mod._BackboneWrapper, "load_weights", fake_load) + del weights["body.layers.0.self_attn.k_proj.weight"] + with pytest.raises(RuntimeError, match="body.layers.0.self_attn.k_proj.weight"): + talker.load_weights(iter(weights.items())) + + def test_load_weights_fails_missing_modality_embedding(self, monkeypatch): + """load_weights raises when modality embedding is missing.""" + talker, weights, mod, fake_load = self._make_fake_talker_and_keys() + monkeypatch.setattr(mod._BackboneWrapper, "load_weights", fake_load) + del weights["tied.embedding.modality_embeddings.0.embedding.weight"] + with pytest.raises(RuntimeError, match="modality_embeddings"): + talker.load_weights(iter(weights.items())) + + def test_load_weights_fails_unknown_prefix(self, monkeypatch): + """load_weights raises on unknown non-codec checkpoint prefix.""" + talker, weights, mod, fake_load = self._make_fake_talker_and_keys() + monkeypatch.setattr(mod._BackboneWrapper, "load_weights", fake_load) + weights["unknown.prefix.weight"] = torch.randn(4) + with pytest.raises(ValueError, match="Unexpected checkpoint key"): + talker.load_weights(iter(weights.items())) + + +# ---- AC-7: Stage Input Processor ---- + + +class TestStageInputProcessor: + def test_revert_delay_pattern(self): + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _revert_delay_pattern, + ) + + # 8 codebooks, 3 real frames -> delayed shape [8, 3+8-1=10] + Q, T = 8, 3 + delayed = torch.full((Q, T + Q - 1), 1024) # Fill with BOC + for i in range(Q): + for t in range(T): + delayed[i, i + t] = i * 100 + t # Real codes at shifted positions + result = _revert_delay_pattern(delayed) + assert result.shape == (Q, T) + for i in range(Q): + for t in range(T): + assert result[i, t].item() == i * 100 + t + + def test_revert_delay_pattern_rejects_wrong_codebooks(self): + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _revert_delay_pattern, + ) + + # 7 codebooks should be rejected + codes = torch.zeros(7, 20) + with pytest.raises(ValueError, match="Expected exactly 8 codebook rows"): + _revert_delay_pattern(codes) + + def test_revert_delay_pattern_rejects_too_few_frames(self): + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _revert_delay_pattern, + ) + + # 8 codebooks but only 5 frames (need at least 8) + codes = torch.zeros(8, 5) + with pytest.raises(ValueError, match="Not enough frames"): + _revert_delay_pattern(codes) + + def test_filter_real_code_frames(self): + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _filter_real_code_frames, + ) + + # 8 codebooks, 4 frames + codes = torch.tensor( + [ + [100, 200, 1024, 300], # cb0: frame 2 has BOC + [101, 201, 1024, 301], + [102, 202, 1024, 302], + [103, 203, 1024, 303], + [104, 204, 1024, 304], + [105, 205, 1024, 305], + [106, 206, 1024, 306], + [107, 207, 1024, 307], + ] + ) + result = _filter_real_code_frames(codes) + # Frame 2 (column 2) has BOC in all codebooks -> filtered out + assert result.shape == (8, 3) + assert result[0, 0].item() == 100 + assert result[0, 1].item() == 200 + assert result[0, 2].item() == 300 + + +# ---- AC-10: Registry ---- + + +class TestRegistry: + def test_talker_registered(self): + from vllm_omni.model_executor.models.registry import _OMNI_MODELS + + assert "HiggsMultimodalQwen3ForConditionalGeneration" in _OMNI_MODELS + assert "HiggsAudioV3TalkerForConditionalGeneration" in _OMNI_MODELS + + def test_code2wav_registered(self): + from vllm_omni.model_executor.models.registry import _OMNI_MODELS + + assert "HiggsAudioV3Code2WavForConditionalGeneration" in _OMNI_MODELS + + def test_pipeline_registered(self): + from vllm_omni.config.pipeline_registry import _OMNI_PIPELINES + + assert "higgs_multimodal_qwen3" in _OMNI_PIPELINES + + def test_deploy_yaml_exists(self): + import os + + # __file__ = tests/unit/higgs_audio_v3/test_*.py → 4 dirnames to repo root + repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) + yaml_path = os.path.join(repo_root, "vllm_omni", "deploy", "higgs_multimodal_qwen3.yaml") + assert os.path.isfile(yaml_path), f"Deploy YAML not found at {yaml_path}" + + +# ---- AC-3: Prompt Builder ---- + + +class TestPromptBuilder: + def _make_mock_tokenizer(self): + """Create a mock tokenizer with the required special tokens.""" + + class MockTokenizer: + def __init__(self): + self._added_vocab = { + "<|tts|>": 151700, + "<|text|>": 151701, + "<|audio|>": 151702, + "<|ref_audio|>": 151703, + "<|ref_text|>": 151704, + } + + def get_added_vocab(self): + return self._added_vocab + + def encode(self, text, add_special_tokens=True): + # Simple word-level tokenization for testing + return list(range(100, 100 + len(text.split()))) + + return MockTokenizer() + + def test_plain_tts_prompt(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + tok = self._make_mock_tokenizer() + adapter = HiggsAudioV3TokenizerAdapter(tok) + ids = adapter.build_prompt("Hello world") + assert ids[0] == 151700 # <|tts|> + assert ids[1] == 151701 # <|text|> + assert ids[-1] == 151702 # <|audio|> + assert len(ids) == 2 + 2 + 1 # tts + text + 2 word tokens + audio + + def test_empty_text_rejected(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + tok = self._make_mock_tokenizer() + adapter = HiggsAudioV3TokenizerAdapter(tok) + with pytest.raises(ValueError, match="non-empty"): + adapter.build_prompt("") + + def test_whitespace_only_rejected(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + tok = self._make_mock_tokenizer() + adapter = HiggsAudioV3TokenizerAdapter(tok) + with pytest.raises(ValueError, match="non-empty"): + adapter.build_prompt(" ") + + def test_missing_specials_rejected(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + class BadTokenizer: + def get_added_vocab(self): + return {"<|tts|>": 1} # Missing <|text|> and <|audio|> + + with pytest.raises(ValueError, match="missing"): + HiggsAudioV3TokenizerAdapter(BadTokenizer()) + + def test_no_voice_clone_tokens_in_plain_tts(self): + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + tok = self._make_mock_tokenizer() + adapter = HiggsAudioV3TokenizerAdapter(tok) + ids = adapter.build_prompt("Hello") + # Should not contain ref_audio or ref_text token IDs + assert 151703 not in ids # <|ref_audio|> + assert 151704 not in ids # <|ref_text|> diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 28ff5267a..a12e6bcd6 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -149,6 +149,10 @@ _OMNI_PIPELINES: dict[str, tuple[str, str]] = { "vllm_omni.model_executor.models.higgs_audio_v2.pipeline", "HIGGS_AUDIO_V2_PIPELINE", ), + "higgs_multimodal_qwen3": ( + "vllm_omni.model_executor.models.higgs_audio_v3.pipeline", + "HIGGS_AUDIO_V3_PIPELINE", + ), "dynin_omni": ( "vllm_omni.model_executor.models.dynin_omni.pipeline", "DYNIN_OMNI_PIPELINE", diff --git a/vllm_omni/data_entry_keys.py b/vllm_omni/data_entry_keys.py index aa0e1005c..2584a7242 100644 --- a/vllm_omni/data_entry_keys.py +++ b/vllm_omni/data_entry_keys.py @@ -63,6 +63,7 @@ class OmniPayloadMeta(TypedDict, total=False): stream_finished: torch.Tensor req_id: list[str] left_context_size: int + right_holdback_size: int override_keys: list[tuple[str, str]] num_processed_tokens: int next_stage_prompt_len: int @@ -150,6 +151,7 @@ class MetaStruct(_StructBase): stream_finished: torch.Tensor | None = None req_id: list[str] | None = None left_context_size: int | None = None + right_holdback_size: int | None = None override_keys: list[tuple[str, str]] | None = None num_processed_tokens: int | None = None next_stage_prompt_len: int | None = None diff --git a/vllm_omni/deploy/higgs_multimodal_qwen3.yaml b/vllm_omni/deploy/higgs_multimodal_qwen3.yaml new file mode 100644 index 000000000..f6742f10a --- /dev/null +++ b/vllm_omni/deploy/higgs_multimodal_qwen3.yaml @@ -0,0 +1,69 @@ +# Higgs-audio v3 deploy: Stage 0 (talker) -> Stage 1 (code2wav) via shared memory. +# +# Plain TTS only (no voice cloning). async_chunk streams codec chunks from +# Stage 0 to Stage 1 as they are produced (TTFA approaches one AR loop's +# worth of work). Stage 1 honors meta.left_context_size + meta.right_holdback_size +# so the per-chunk codec windows stitch into a coherent PCM stream. +async_chunk: true + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + # Stream PCM bytes back to the client as Stage 1 emits each chunk. + codec_streaming: true + # Streaming chunk shape for talker2code2wav_async_chunk: + # codec_chunk_frames steady-state new frames per emit + # codec_left_context_frames sliding past context to avoid leading-edge artifacts + # codec_right_holdback_frames trailing frames held back so the codec + # has future context for emit-region tail samples + # initial_codec_chunk_frames smaller first chunk to minimize TTFA + codec_chunk_frames: 25 + codec_left_context_frames: 25 + codec_right_holdback_frames: 4 + initial_codec_chunk_frames: 1 + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 5000 + connector_get_max_wait: 500 + +stages: + - stage_id: 0 + max_num_seqs: 16 + gpu_memory_utilization: 0.6 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false + async_scheduling: false + max_num_batched_tokens: 4096 + max_model_len: 8192 + devices: "0" + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 1.0 + top_p: 0.95 + top_k: 50 + max_tokens: 2048 + seed: 42 + repetition_penalty: 1.0 + + - stage_id: 1 + max_num_seqs: 16 + gpu_memory_utilization: 0.25 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false + async_scheduling: false + max_num_batched_tokens: 65536 + max_model_len: 65536 + 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/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index 9a858868e..a18687ca2 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -115,6 +115,54 @@ class OpenAICreateSpeechRequest(BaseModel): raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)") return v + @model_validator(mode="before") + @classmethod + def normalize_references_alias(cls, data): + """Map the BosonAI Higgs Audio v3 cookbook ``references`` array onto ``ref_audio`` / ``ref_text``. + + Upstream Higgs Audio v3 examples and the boson.ai cookbook send voice + clones as:: + + {"references": [{"audio_path": str, "text": str}]} + + vllm-omni's existing convention is ``ref_audio`` + ``ref_text``. + Without this alias pydantic silently drops the unknown ``references`` + field and the request falls through to zero-shot synthesis with no + error, which is hard to debug from the client side. The normalizer + translates a single reference and rejects multi-reference payloads + (vllm-omni does not yet support multi-shot voice clone) or conflicts + with the explicit ``ref_audio`` / ``ref_text`` fields. + """ + if not isinstance(data, dict): + return data + refs = data.get("references") + if refs is None: + return data + if not isinstance(refs, list) or len(refs) == 0: + raise ValueError("'references' must be a non-empty array of {audio_path, text} objects") + if len(refs) > 1: + raise ValueError("'references' only supports a single reference; multi-shot voice clone is not supported") + first = refs[0] + if not isinstance(first, dict): + raise ValueError("'references[0]' must be an object with 'audio_path' and optional 'text'") + audio_path = first.get("audio_path") + if not isinstance(audio_path, str) or not audio_path: + raise ValueError( + "'references[0].audio_path' is required and must be a string (URL, data: URI, or file path)" + ) + existing_ref_audio = data.get("ref_audio") + if existing_ref_audio and existing_ref_audio != audio_path: + raise ValueError("'references' and 'ref_audio' are mutually exclusive") + data["ref_audio"] = audio_path + text = first.get("text") + if isinstance(text, str) and text.strip(): + existing_ref_text = data.get("ref_text") + if existing_ref_text and existing_ref_text != text: + raise ValueError("'references[0].text' and 'ref_text' conflict; supply only one") + data["ref_text"] = text + data.pop("references", None) + return data + @model_validator(mode="before") @classmethod def reject_higgs_audio_v2_unsupported_aliases(cls, data): diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 57bed35eb..3b5957602 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -76,6 +76,7 @@ _MING_TTS_MODEL_STAGES = {"ming_tts"} _MOSS_TTS_MODEL_STAGES = {"moss_tts_nano"} _MOSS_TTS_FULL_MODEL_STAGES = {"moss_tts", "moss_tts_codec"} _HIGGS_AUDIO_V2_TTS_MODEL_STAGES = {"higgs_audio_v2"} +_HIGGS_AUDIO_V3_TTS_MODEL_STAGES = {"higgs_audio_v3"} _GLM_TTS_MODEL_STAGES = {"glm_tts"} _TTS_MODEL_STAGES: set[str] = ( _VOXTRAL_TTS_MODEL_STAGES @@ -84,6 +85,7 @@ _TTS_MODEL_STAGES: set[str] = ( | _COSYVOICE3_TTS_MODEL_STAGES | _OMNIVOICE_TTS_MODEL_STAGES | _HIGGS_AUDIO_V2_TTS_MODEL_STAGES + | _HIGGS_AUDIO_V3_TTS_MODEL_STAGES | _COVO_AUDIO_MODEL_STAGES | _VOXCPM2_TTS_MODEL_STAGES | _MING_TTS_MODEL_STAGES @@ -98,6 +100,7 @@ _SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = { "cosyvoice3", "voxcpm2", "higgs_audio_v2", + "higgs_audio_v3", } _TTS_LANGUAGES: set[str] = { "Auto", @@ -594,6 +597,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return "moss_tts" if model_stage in _HIGGS_AUDIO_V2_TTS_MODEL_STAGES: return "higgs_audio_v2" + if model_stage in _HIGGS_AUDIO_V3_TTS_MODEL_STAGES: + return "higgs_audio_v3" if model_stage in _GLM_TTS_MODEL_STAGES: return "glm_tts" return None @@ -934,6 +939,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): "moss_tts_nano", "glm_tts", "higgs_audio_v2", + "higgs_audio_v3", ): label = { "cosyvoice3": "CosyVoice3", @@ -941,6 +947,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): "omnivoice": "OmniVoice", "moss_tts_nano": "MOSS-TTS-Nano", "higgs_audio_v2": "Higgs-Audio V2", + "higgs_audio_v3": "Higgs-Audio V3", "glm_tts": "GLM-TTS", }.get(self._tts_model_type, self._tts_model_type) return ( @@ -1896,6 +1903,84 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): self._higgs_audio_v2_processor = processor return processor + # ---- higgs-audio v3 ---- + + def _validate_higgs_audio_v3_request(self, request: OpenAICreateSpeechRequest) -> str | None: + """Validate higgs_audio_v3 request parameters.""" + if not request.input or not request.input.strip(): + return "higgs_audio_v3: input text cannot be empty" + if request.ref_audio is not None and not request.ref_text: + # Voice clone ref_text is optional for v3 (improves fidelity but not required) + pass + if request.max_new_tokens is not None: + if request.max_new_tokens < _TTS_MAX_NEW_TOKENS_MIN: + return f"max_new_tokens must be at least {_TTS_MAX_NEW_TOKENS_MIN}" + return None + + 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, + builds ``[tts, (ref_text, tokens,) ref_audio, -100xN, text, tokens, audio]``. + """ + 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 = await self._resolve_ref_audio(request.ref_audio) + wav = np.asarray(wav_list, dtype=np.float32) + ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, int(sr)) + ref_codes_delayed = apply_delay_pattern(ref_codes_raw) + + prompt_ids = adapter.build_prompt( + request.input, + num_ref_tokens=int(ref_codes_delayed.shape[0]), + reference_text=request.ref_text or None, + ) + 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), + } + return prompt + + 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 + def _validate_fish_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: """Validate Fish Speech request parameters. Returns error message or None.""" if not request.input or not request.input.strip(): @@ -2796,6 +2881,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): additional["voice_name"] = voice_lower additional["voice_created_at"] = self._voice_created_at(voice_lower) tts_params = {} + elif self._tts_model_type == "higgs_audio_v3": + err = self._apply_uploaded_speaker(request) + if err: + raise ValueError(err) + validation_error = self._validate_higgs_audio_v3_request(request) + if validation_error: + raise ValueError(validation_error) + prompt = await self._build_higgs_audio_v3_params(request) + tts_params = {} elif self._tts_model_type == "voxcpm2": # voxcpm2 doesn't use `_apply_uploaded_speaker` because the prompt builder needs the # raw waveform tuple for prefill-length accounting, not a base64 data URL. diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/__init__.py b/vllm_omni/model_executor/models/higgs_audio_v3/__init__.py new file mode 100644 index 000000000..052d51eef --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/__init__.py @@ -0,0 +1,9 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""vllm-omni integration for boson-ai's higgs-audio v3 (two-stage TTS).""" + +from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, +) + +__all__ = ["HiggsAudioV3Config"] diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/configuration_higgs_audio_v3.py b/vllm_omni/model_executor/models/higgs_audio_v3/configuration_higgs_audio_v3.py new file mode 100644 index 000000000..bed0467c6 --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/configuration_higgs_audio_v3.py @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Configuration class for higgs-audio v3 (HiggsMultimodalQwen3) in vllm-omni. + +``HiggsAudioV3Config.from_pretrained(model_path)`` returns a config with +``tts_token_id``, ``text_token_id``, ``audio_continuation_id``, and +``eos_token_id`` already resolved from the checkpoint tokenizer. If the +tokenizer is unavailable or missing required specials, the load raises. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import transformers +from transformers import AutoConfig, PretrainedConfig + +__all__ = ["HiggsAudioV3Config"] + +logger = logging.getLogger(__name__) + +_QWEN3_ROPE_THETA = 1_000_000 + +_REQUIRED_SPECIALS = ("<|tts|>", "<|text|>", "<|audio|>") + + +def _build_text_config(raw: Any) -> PretrainedConfig: + if isinstance(raw, PretrainedConfig): + return raw + cfg = dict(raw or {}) + model_type = cfg.get("model_type", "qwen3") + if model_type == "qwen3" and cfg.get("rope_theta") is None: + cfg["rope_theta"] = _QWEN3_ROPE_THETA + try: + cfg_cls = transformers.CONFIG_MAPPING[model_type] + except KeyError as exc: + raise ValueError(f"Unknown text backbone model_type {model_type!r}") from exc + return cfg_cls(**cfg) + + +def _resolve_model_dir(pretrained_model_name_or_path: str) -> str | None: + """Resolve a model name/path to a local directory containing tokenizer files.""" + if os.path.isdir(pretrained_model_name_or_path): + return pretrained_model_name_or_path + try: + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache(repo_id=pretrained_model_name_or_path, filename="tokenizer_config.json") + if isinstance(cached, str) and os.path.isfile(cached): + return os.path.dirname(cached) + except Exception: + pass + + try: + from huggingface_hub.constants import HF_HUB_CACHE + + safe = pretrained_model_name_or_path.replace("/", "--") + snapshots = os.path.join(HF_HUB_CACHE, f"models--{safe}", "snapshots") + if os.path.isdir(snapshots): + for rev in os.listdir(snapshots): + candidate = os.path.join(snapshots, rev) + if os.path.isfile(os.path.join(candidate, "tokenizer_config.json")): + return candidate + except Exception: + pass + return None + + +class HiggsAudioV3Config(PretrainedConfig): + """Typed config for higgs-audio v3 (HiggsMultimodalQwen3). + + ``from_pretrained()`` automatically resolves ``<|tts|>``, ``<|text|>``, + ``<|audio|>`` and ``eos_token_id`` from the checkpoint tokenizer. + """ + + model_type: str = "higgs_multimodal_qwen3" + is_composition = True + + def __init__( + self, + audio_encoder_config: dict[str, Any] | None = None, + text_config: dict[str, Any] | PretrainedConfig | None = None, + audio_token_id: int = -100, + mel_per_sample: int = 8, + num_codebooks: int = 8, + codebook_size: int = 1026, + audio_stream_bos_id: int = 1024, + audio_stream_eos_id: int = 1025, + sample_rate: int = 24000, + frame_rate: int = 25, + tts_token_id: int | None = None, + text_token_id: int | None = None, + audio_continuation_id: int | None = None, + **kwargs: Any, + ) -> None: + self.audio_token_id = audio_token_id + self.mel_per_sample = mel_per_sample + + if audio_encoder_config is None: + audio_encoder_config = { + "encoder_type": "discrete", + "num_codebooks": num_codebooks, + "vocab_size": codebook_size, + "tie_word_embeddings": True, + } + self.audio_encoder_config = audio_encoder_config + + self.num_codebooks = int(audio_encoder_config.get("num_codebooks", num_codebooks)) + if self.num_codebooks <= 0: + raise ValueError(f"num_codebooks must be > 0, got {self.num_codebooks}") + self.codebook_size = int(audio_encoder_config.get("vocab_size", codebook_size)) + self.tie_modality_embeddings = bool(audio_encoder_config.get("tie_word_embeddings", True)) + + self.audio_stream_bos_id = audio_stream_bos_id + self.audio_stream_eos_id = audio_stream_eos_id + self.sample_rate = sample_rate + self.frame_rate = frame_rate + + self.text_config = _build_text_config(text_config) + self.audio_hidden_size = int(audio_encoder_config.get("out_dim", self.text_config.hidden_size)) + + self.tts_token_id = tts_token_id + self.text_token_id = text_token_id + self.audio_continuation_id = audio_continuation_id + + super().__init__(**kwargs) + + @classmethod + def from_pretrained(cls, pretrained_model_name_or_path: str, **kwargs: Any) -> HiggsAudioV3Config: + """Load config and resolve special token IDs from the checkpoint tokenizer. + + Passes the original ``pretrained_model_name_or_path`` (local dir or + HF repo ID) directly to ``AutoTokenizer.from_pretrained()`` so it can + handle cache hits, downloads, and local paths uniformly. Raises if the + tokenizer is missing required specials. + """ + config = super().from_pretrained(pretrained_model_name_or_path, **kwargs) + config.resolve_special_tokens(pretrained_model_name_or_path) + return config + + def get_text_config(self, decoder: bool = False) -> PretrainedConfig: + del decoder + return self.text_config + + @property + def num_real_codes(self) -> int: + return self.audio_stream_bos_id + + @property + def hidden_size(self) -> int: + return self.text_config.hidden_size + + def resolve_special_tokens(self, model_path: str) -> None: + """Resolve <|tts|>, <|text|>, <|audio|> and eos from the HF tokenizer. + + Raises ``ValueError`` if any of the 3 required specials is missing + from the tokenizer's added vocabulary. + """ + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) + vocab = dict(tokenizer.get_added_vocab()) + + missing = [t for t in _REQUIRED_SPECIALS if t not in vocab] + if missing: + raise ValueError( + f"Tokenizer at {model_path} is missing required Higgs TTS v3 " + f"special tokens: {missing}. Available added tokens: " + f"{sorted(vocab.keys())[:20]}" + ) + + self.tts_token_id = vocab["<|tts|>"] + self.text_token_id = vocab["<|text|>"] + self.audio_continuation_id = vocab["<|audio|>"] + + if hasattr(tokenizer, "eos_token_id") and tokenizer.eos_token_id is not None: + self.eos_token_id = int(tokenizer.eos_token_id) + + +AutoConfig.register("higgs_multimodal_qwen3", HiggsAudioV3Config) diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_code2wav.py b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_code2wav.py new file mode 100644 index 000000000..2baf70b4d --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_code2wav.py @@ -0,0 +1,524 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Stage 1 (codec decoder) for higgs-audio v3. + +Reuses higgs-audio-v2's RVQ + DAC codec decoder but loads weights from the +v3 checkpoint's bundled codec (``tied.embedding.modality_embeddings.0.model.*`` +prefix) rather than from a standalone tokenizer repo. +""" + +from __future__ import annotations + +import os +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_decoder import ( + HiggsAudioRVQ, + build_boson_dac_decoder, + build_higgs_audio_acoustic_decoder, + load_higgs_audio_codec, +) +from vllm_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, +) +from vllm_omni.model_executor.models.output_templates import OmniOutput + +__all__ = [ + "HiggsAudioV3Code2Wav", + "HiggsAudioV3Code2WavForConditionalGeneration", +] + +logger = init_logger(__name__) + +# Prefix under which codec weights are stored in the v3 checkpoint. +_CODEC_PREFIX = "tied.embedding.modality_embeddings.0.model." + + +class HiggsAudioV3Code2Wav(nn.Module): + """Stage-1 codec decoder for higgs-audio v3. + + The codec architecture is identical to v2 (HiggsAudioRVQ + fc2 + + BosonDacDecoder); only the weight loading path differs. V3 bundles + codec weights inside the main checkpoint under a known prefix, while + v2 uses a standalone tokenizer repo. + + For backward compatibility and simplicity, we delegate to the v2 + ``load_higgs_audio_codec`` function when the audio tokenizer repo + is available. When not (or as primary path), we extract codec weights + from the v3 safetensors via the engine's weight iterator. + """ + + input_modalities = "audio" + + def __init__( + self, + config: HiggsAudioV3Config | None = None, + *, + vllm_config: VllmConfig | None = None, + prefix: str = "", + ): + super().__init__() + + if vllm_config is not None: + hf_config = vllm_config.model_config.hf_config + if isinstance(hf_config, HiggsAudioV3Config): + self.config = hf_config + else: + self.config = HiggsAudioV3Config(**hf_config.to_dict()) + self._model_path: str | None = vllm_config.model_config.model + self.vllm_config: VllmConfig | None = vllm_config + else: + if config is None: + raise TypeError("HiggsAudioV3Code2Wav: provide either `config` or `vllm_config`.") + self.config = config + self._model_path = None + self.vllm_config = None + + self.sample_rate: int = int(self.config.sample_rate) + self.num_codebooks: int = int(self.config.num_codebooks) + self.num_real_codes: int = int(self.config.num_real_codes) + self.hop_length: int = 960 + + # Engine-runner hooks + 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 + + # Codec modules (populated by load_weights) + self.quantizer: HiggsAudioRVQ | None = None + self.fc2: nn.Linear | None = None + self.acoustic_decoder: nn.Module | None = None + self._loaded: bool = False + # Do NOT eagerly load from standalone tokenizer here. + # load_weights() will try bundled V3 codec first, then fall back. + + def _load_from_tokenizer_repo(self) -> None: + """Load codec from the standalone higgs-audio-v2-tokenizer repo.""" + tokenizer_id = "bosonai/higgs-audio-v2-tokenizer" + # Try to resolve from HF cache + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache(repo_id=tokenizer_id, filename="config.json") + if isinstance(cached, str) and os.path.isfile(cached): + tokenizer_dir = os.path.dirname(cached) + else: + from huggingface_hub.constants import HF_HUB_CACHE + + safe = tokenizer_id.replace("/", "--") + snapshots_dir = os.path.join(HF_HUB_CACHE, f"models--{safe}", "snapshots") + if os.path.isdir(snapshots_dir): + tokenizer_dir = None + for rev in os.listdir(snapshots_dir): + candidate = os.path.join(snapshots_dir, rev) + if os.path.isfile(os.path.join(candidate, "config.json")): + tokenizer_dir = candidate + break + if tokenizer_dir is None: + raise FileNotFoundError(f"No cached snapshot for {tokenizer_id}") + else: + raise FileNotFoundError(f"No cached snapshot for {tokenizer_id}") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + quantizer, fc2, acoustic_decoder, _cfg = load_higgs_audio_codec(tokenizer_dir, device) + self.quantizer = quantizer + self.fc2 = fc2 + self.acoustic_decoder = acoustic_decoder + self._loaded = True + logger.info("Loaded HiggsAudioV3Code2Wav from standalone tokenizer repo.") + + # ------------------------------------------------------------------ engine 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: Any, sampling_metadata: Any = None) -> None: + return None + + # ------------------------------------------------------------------ load + def load_weights(self, weights_or_model_dir, device: torch.device | None = None): + if self._loaded: + if not isinstance(weights_or_model_dir, (str, bytes, os.PathLike)): + for _ in weights_or_model_dir: + pass + return {name for name, _ in self.named_parameters()} + + if not isinstance(weights_or_model_dir, (str, bytes, os.PathLike)): + # Engine path: try to extract V3 bundled codec weights first + codec_state: dict[str, torch.Tensor] = {} + for name, tensor in weights_or_model_dir: + if name.startswith(_CODEC_PREFIX): + stripped = name[len(_CODEC_PREFIX) :] + codec_state[stripped] = tensor + + if codec_state: + # Bundled keys found — load strictly, no fallback on error. + self._load_from_bundled_state(codec_state, device) + logger.info( + "Loaded HiggsAudioV3Code2Wav from bundled V3 checkpoint (%d codec keys consumed).", + len(codec_state), + ) + return {name for name, _ in self.named_parameters()} + + # No bundled keys — fallback to standalone tokenizer repo + if not self._loaded: + try: + self._load_from_tokenizer_repo() + except (FileNotFoundError, OSError) as exc2: + raise RuntimeError( + "HiggsAudioV3Code2Wav: could not load codec weights. " + "Neither V3 bundled codec keys (tied.embedding." + "modality_embeddings.0.model.*) were found in the " + "checkpoint, nor is bosonai/higgs-audio-v2-tokenizer " + f"cached in HF_HOME. Last error: {exc2}" + ) from exc2 + return {name for name, _ in self.named_parameters()} + + # File-based path + if not self._loaded: + self._load_from_tokenizer_repo() + return None + + def _load_from_bundled_state( + self, + codec_state: dict[str, torch.Tensor], + device: torch.device | None = None, + ) -> None: + """Load codec from V3 checkpoint's bundled weights. + + Keys arrive with the ``tied.embedding.modality_embeddings.0.model.`` + prefix already stripped. We remap to the same layout that + ``load_higgs_audio_codec`` expects and build the modules. + """ + from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_decoder import ( + _remap_boson_model_pth_state_dict, + ) + + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + # The bundled codec may use boson-ai key naming. Try remapping. + needs_remap = any(k.startswith("quantizer.vq.layers.") for k in codec_state) + if needs_remap: + codec_state = _remap_boson_model_pth_state_dict(codec_state) + + # Discover parameters from the state dict + proj_key = "quantizer.quantizers.0.project_out.weight" + if proj_key not in codec_state: + raise KeyError( + f"Bundled codec state dict missing '{proj_key}'. " + f"Available keys (first 10): {list(codec_state.keys())[:10]}" + ) + hidden_size = codec_state[proj_key].shape[0] + codebook_dim = 64 + codebook_size = 1024 + required_quantizers = self.num_codebooks + + # Build RVQ — require exactly num_codebooks quantizers + quantizer = HiggsAudioRVQ( + num_quantizers=required_quantizers, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + hidden_size=hidden_size, + ).to(device) + for i in range(required_quantizers): + prefix = f"quantizer.quantizers.{i}" + # Require codebook weight + embed_key = None + for key_suffix in [".codebook.embed", ".codebook.weight"]: + candidate = f"{prefix}{key_suffix}" + if candidate in codec_state: + embed_key = candidate + break + if embed_key is None: + raise KeyError( + f"Bundled codec missing codebook weight for quantizer {i}. " + f"Expected {prefix}.codebook.embed or {prefix}.codebook.weight" + ) + quantizer.quantizers[i].codebook.weight.data.copy_(codec_state[embed_key].to(device)) + # Require projector weights + proj_w = f"{prefix}.project_out.weight" + proj_b = f"{prefix}.project_out.bias" + if proj_w not in codec_state: + raise KeyError(f"Bundled codec missing {proj_w}") + if proj_b not in codec_state: + raise KeyError(f"Bundled codec missing {proj_b}") + quantizer.quantizers[i].project_out.weight.data.copy_(codec_state[proj_w].to(device)) + quantizer.quantizers[i].project_out.bias.data.copy_(codec_state[proj_b].to(device)) + + # Build fc2 + fc2_w = codec_state.get("fc2.weight") + fc2_b = codec_state.get("fc2.bias") + if fc2_w is None or fc2_b is None: + raise KeyError( + "Bundled codec state dict missing fc2.weight and/or fc2.bias. " + f"Available keys (first 10): {list(codec_state.keys())[:10]}" + ) + fc2 = nn.Linear(fc2_w.shape[1], fc2_w.shape[0]).to(device) + fc2.weight.data.copy_(fc2_w.to(device)) + fc2.bias.data.copy_(fc2_b.to(device)) + + # Build acoustic decoder — detect layout from key names. + # V3 checkpoint uses OmniVoice layout (block.N/conv1/conv2/snake1), + # not boson-ai standalone layout (model.0/model.1). + ad_keys = { + k[len("acoustic_decoder.") :]: v for k, v in codec_state.items() if k.startswith("acoustic_decoder.") + } + is_boson_layout = any(k.startswith("model.") for k in ad_keys) + if is_boson_layout: + acoustic_decoder = build_boson_dac_decoder(device) + else: + # OmniVoice layout — need the v2 tokenizer config for DacConfig + tokenizer_config = { + "acoustic_model_config": { + "codebook_dim": 8, + "codebook_size": 1024, + "decoder_hidden_size": 1024, + "downsampling_ratios": [8, 5, 4, 2, 3], + "encoder_hidden_size": 64, + "hidden_size": 256, + "hop_length": 960, + "model_type": "dac", + "n_codebooks": 9, + "sampling_rate": 16000, + "upsampling_ratios": [8, 5, 4, 2, 3], + } + } + acoustic_decoder = build_higgs_audio_acoustic_decoder(tokenizer_config, device) + if not ad_keys: + raise KeyError( + "Bundled codec state dict has no acoustic_decoder.* keys. " + f"Available keys (first 10): {list(codec_state.keys())[:10]}" + ) + load_report = acoustic_decoder.load_state_dict(ad_keys, strict=False) + if load_report.missing_keys: + raise RuntimeError( + f"Bundled DAC decoder missing {len(load_report.missing_keys)} keys: {load_report.missing_keys[:5]}..." + ) + if load_report.unexpected_keys: + raise RuntimeError( + f"Bundled DAC decoder has {len(load_report.unexpected_keys)} unexpected keys: " + f"{load_report.unexpected_keys[:5]}..." + ) + acoustic_decoder.eval() + + # Verify required decoder-side codec keys were consumed. The V3 + # checkpoint also bundles encoder-side keys (acoustic_encoder.*, + # encoder_semantic.*, decoder_semantic.*, semantic_model.*, fc.*, fc1.*) + # that the DAC decoder does not need — those are allowed but ignored. + consumed: set[str] = set() + for i in range(required_quantizers): + prefix = f"quantizer.quantizers.{i}" + for suffix in [".codebook.embed", ".codebook.weight"]: + if f"{prefix}{suffix}" in codec_state: + consumed.add(f"{prefix}{suffix}") + consumed.add(f"{prefix}.project_out.weight") + consumed.add(f"{prefix}.project_out.bias") + consumed.add("fc2.weight") + consumed.add("fc2.bias") + for k in codec_state: + if k.startswith("acoustic_decoder."): + consumed.add(k) + + # Known unused prefixes: encoder-side modules and quantizer training + # artifacts (cluster_size, embed_avg, inited, project_in) that are + # bundled in the checkpoint but not needed for inference decoding. + _KNOWN_UNUSED_PREFIXES = ( + "acoustic_encoder.", + "encoder_semantic.", + "decoder_semantic.", + "semantic_model.", + "fc.", + "fc1.", + ) + # Quantizer training-only suffixes (per-codebook) + _QUANTIZER_TRAINING_SUFFIXES = ( + ".codebook.cluster_size", + ".codebook.embed_avg", + ".codebook.inited", + ".project_in.weight", + ".project_in.bias", + ) + unconsumed = set() + for k in codec_state: + if k in consumed: + continue + if any(k.startswith(p) for p in _KNOWN_UNUSED_PREFIXES): + continue + if any(k.endswith(s) for s in _QUANTIZER_TRAINING_SUFFIXES): + continue + unconsumed.add(k) + if unconsumed: + raise RuntimeError( + f"Bundled codec has {len(unconsumed)} unexpected decoder-side keys: {sorted(unconsumed)[:10]}..." + ) + + self.quantizer = quantizer + self.fc2 = fc2 + self.acoustic_decoder = acoustic_decoder + self._loaded = True + + # ------------------------------------------------------------------ decode + @torch.inference_mode() + def decode_codes(self, audio_codes: torch.Tensor) -> torch.Tensor: + """Decode [B, num_codebooks=8, T] codes to PCM [B, 1, T*960].""" + if not self._loaded: + raise RuntimeError("HiggsAudioV3Code2Wav not loaded.") + + codes = self._validate_codes(audio_codes) + rvq_codes = codes.transpose(0, 1).long() + quantized = self.quantizer.decode(rvq_codes) + quantized = quantized.to(dtype=self.fc2.weight.dtype) + quantized = self.fc2(quantized.transpose(1, 2)).transpose(1, 2) + first_param = next(self.acoustic_decoder.parameters(), None) + if first_param is not None and quantized.dtype != first_param.dtype: + quantized = quantized.to(dtype=first_param.dtype) + audio = self.acoustic_decoder(quantized) + if audio.dim() == 2: + audio = audio.unsqueeze(1) + return audio.to(codes.device) + + @torch.inference_mode() + def forward_chunk( + self, + audio_codes: torch.Tensor, + *, + left_context_size: int = 0, + right_holdback_size: int = 0, + hop_length: int | None = None, + ) -> torch.Tensor: + # ``left_context_size`` frames trimmed off the front (already-emitted + # prefix). ``right_holdback_size`` frames trimmed off the end + # (deferred to the next chunk so the codec has future context for + # the emit region's trailing samples). + hop = int(hop_length) if hop_length is not None else self.hop_length + pcm = self.decode_codes(audio_codes) + right_trim = right_holdback_size * hop + left_trim = left_context_size * hop + if right_trim > 0: + if pcm.shape[-1] <= right_trim: + return pcm[..., :0] + pcm = pcm[..., :-right_trim] + if left_trim == 0: + return pcm + if pcm.shape[-1] <= left_trim: + return pcm[..., :0] + return pcm[..., left_trim:] + + # ------------------------------------------------------------------ runtime forward + @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, + ) -> torch.Tensor | OmniOutput: + if isinstance(input_ids, torch.Tensor) and input_ids.ndim == 3: + return self.decode_codes(input_ids) + + sr_val = int(self.sample_rate) + sr_tensor = torch.tensor(sr_val, dtype=torch.int32) + empty = torch.zeros((0,), dtype=torch.float32) + + 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) + request_ids_list = self._split_request_ids(ids, kwargs.get("seq_token_counts")) + + left_context_size = [0] * len(request_ids_list) + right_holdback_size = [0] * len(request_ids_list) + if runtime_additional_information is not None: + for i, info in enumerate(runtime_additional_information): + if i >= len(left_context_size): + break + meta = info.get("meta", {}) if isinstance(info, dict) else {} + if "left_context_size" in meta: + left_context_size[i] = int(meta["left_context_size"]) + if "right_holdback_size" in meta: + right_holdback_size[i] = int(meta["right_holdback_size"]) + + wavs: list[torch.Tensor] = [] + for i, req_ids in enumerate(request_ids_list): + n = int(req_ids.numel()) + if n == 0: + wavs.append(empty) + continue + if n % self.num_codebooks != 0: + logger.warning( + "HiggsAudioV3Code2Wav: flat code length %d not divisible by %d", + n, + self.num_codebooks, + ) + wavs.append(empty) + continue + frames = n // self.num_codebooks + codes_qf = req_ids.reshape(self.num_codebooks, frames) + codes_bqf = codes_qf.unsqueeze(0) + try: + pcm = self.forward_chunk( + codes_bqf, + left_context_size=left_context_size[i], + right_holdback_size=right_holdback_size[i], + hop_length=self.hop_length, + ) + except ValueError as exc: + logger.warning("HiggsAudioV3Code2Wav: decode skipped (%s)", exc) + wavs.append(empty) + continue + wavs.append(pcm.squeeze(0).squeeze(0).to(torch.float32).cpu()) + + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={ + "model_outputs": wavs, + "sr": [sr_tensor] * len(wavs), + }, + ) + + # ------------------------------------------------------------------ helpers + def _validate_codes(self, audio_codes: torch.Tensor) -> torch.Tensor: + if audio_codes.ndim != 3: + raise ValueError( + f"audio_codes must have shape [B, {self.num_codebooks}, T]; got {tuple(audio_codes.shape)}" + ) + if int(audio_codes.shape[1]) != self.num_codebooks: + raise ValueError(f"dim 1 must equal num_codebooks={self.num_codebooks}; got {int(audio_codes.shape[1])}") + # ``.item()`` triggers a GPU->CPU sync and is illegal inside CUDA-graph + # capture (the dummy run before warmup hits this path). Skip the + # range check while a graph is being captured; the per-step validator + # still fires at real inference time. + if audio_codes.numel() > 0 and not torch.cuda.is_current_stream_capturing(): + max_val = int(audio_codes.max().item()) + min_val = int(audio_codes.min().item()) + if max_val >= self.num_real_codes or min_val < 0: + raise ValueError( + f"audio_codes out of range: min={min_val}, max={max_val}; expected [0, {self.num_real_codes - 1}]" + ) + return audio_codes + + @staticmethod + def _split_request_ids(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] + int(count)) + n = int(ids.numel()) + return [ids[boundaries[i] : min(boundaries[i + 1], n)] for i in range(len(seq_token_counts))] + return [ids] + + +HiggsAudioV3Code2WavForConditionalGeneration = HiggsAudioV3Code2Wav 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 new file mode 100644 index 000000000..4fc89341c --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py @@ -0,0 +1,958 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Stage-0 talker for higgs-audio v3 (Qwen3 backbone, fused multi-codebook). + +Architecture: +- Backbone: Qwen3 (~4B, 36 layers, 2560 hidden, GQA 32/8). No DualFFN. +- Fused multi-codebook embedding: [N*V, D] weight, offset lookup, sum across N +- Fused multi-codebook head: same weight (tied), reshape to [L, N, V] +- MusicGen-style delay pattern [0,1,...,7] with BOC/EOC +- Audio feedback: replace continuation-token embedding with fused codebook embed + +Weight loading maps from the HF checkpoint's prefixes: + tied.embedding.text_embedding. -> model.embed_tokens. + body.layers. -> model.layers. + body.norm. -> model.norm. + tied.head.text_head. -> lm_head. + tied.embedding.modality_embeddings.0.embedding. -> multimodal_embedding. + tied.embedding.modality_embeddings.0.model.* -> skipped (codec for code2wav) + tied.head.modality_heads.0.* -> skipped when tied +""" + +from __future__ import annotations + +import copy +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from vllm.config import VllmConfig +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_omni.model_executor.models.higgs_audio_v3.configuration_higgs_audio_v3 import ( + HiggsAudioV3Config, +) +from vllm_omni.model_executor.models.output_templates import OmniOutput + +__all__ = ["HiggsAudioV3TalkerForConditionalGeneration"] + +logger = init_logger(__name__) + +# Delay pattern constants +BOC_ID = 1024 # beginning of codebook +EOC_ID = 1025 # end of codebook + +# Checkpoint prefix mapping +_BACKBONE_PREFIX_MAP = { + "tied.embedding.text_embedding.": "model.embed_tokens.", + "body.layers.": "model.layers.", + "body.norm.": "model.norm.", + "tied.head.text_head.": "lm_head.", +} +_MODALITY_EMBEDDING_PREFIX = "tied.embedding.modality_embeddings.0.embedding." +_MODALITY_HEAD_PREFIX = "tied.head.modality_heads.0." +_CODEC_PREFIX = "tied.embedding.modality_embeddings.0.model." + + +class HiggsFusedMultiTextEmbedding(nn.Module): + """Fused multi-codebook embedding: [N*V, D] weight + offset lookup.""" + + def __init__(self, num_codebooks: int, vocab_size: int, hidden_size: int): + super().__init__() + self.weight = nn.Parameter(torch.empty(num_codebooks * vocab_size, hidden_size)) + self.num_codebooks = num_codebooks + self.vocab_size = vocab_size + + def forward(self, codes: torch.Tensor) -> torch.Tensor: + N = self.num_codebooks + V = self.vocab_size + offsets = torch.arange(N, device=codes.device, dtype=codes.dtype) * V + fused_ids = codes + offsets + return F.embedding(fused_ids, self.weight).sum(dim=-2) + + +class HiggsFusedMultiTextHead(nn.Module): + """Fused multi-codebook head: [L, D] -> [L, N, V] via one linear.""" + + def __init__(self, num_codebooks: int, vocab_size: int, hidden_size: int): + super().__init__() + self.weight = nn.Parameter(torch.empty(num_codebooks * vocab_size, hidden_size)) + self.num_codebooks = num_codebooks + self.vocab_size = vocab_size + + def generate(self, hidden: torch.Tensor) -> torch.Tensor: + logits = F.linear(hidden, self.weight) + return logits.reshape(hidden.shape[0], self.num_codebooks, self.vocab_size) + + +class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): + """Stage-0 talker for higgs-audio v3. + + Wraps Qwen3Model backbone + fused multi-codebook modules for TTS generation + with MusicGen-style delay pattern sampling and audio feedback embedding. + """ + + # Tell the AR runner to call model.sample() instead of the stock sampler. + prefer_model_sampler: bool = True + # Tell the runner to call postprocess() to emit per-step audio codes. + have_multimodal_outputs: bool = True + has_postprocess: bool = True + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + + hf_config = vllm_config.model_config.hf_config + if isinstance(hf_config, HiggsAudioV3Config): + self.config = hf_config + else: + self.config = HiggsAudioV3Config(**hf_config.to_dict()) + + self.vllm_config = vllm_config + self.num_codebooks = int(self.config.num_codebooks) + self.codebook_size = int(self.config.codebook_size) + hidden_size = int(self.config.audio_hidden_size) + self.tie_modality = self.config.tie_modality_embeddings + + # Fused multi-codebook modules + self.multimodal_embedding = HiggsFusedMultiTextEmbedding(self.num_codebooks, self.codebook_size, hidden_size) + self.modality_head = HiggsFusedMultiTextHead(self.num_codebooks, self.codebook_size, hidden_size) + if self.tie_modality: + self.modality_head.weight = self.multimodal_embedding.weight + + # Qwen3 backbone + self._backbone_config = self.config.text_config + backbone_vllm_config = copy.copy(vllm_config) + backbone_model_config = copy.copy(vllm_config.model_config) + backbone_model_config.hf_config = self._backbone_config + backbone_vllm_config.model_config = backbone_model_config + + self.model = Qwen3Model( + vllm_config=backbone_vllm_config, + prefix=f"{prefix}.model" if prefix else "model", + ) + + if self._backbone_config.tie_word_embeddings: + self.lm_head = self.model.embed_tokens + else: + self.lm_head = ParallelLMHead( + self._backbone_config.vocab_size, + self._backbone_config.hidden_size, + prefix=f"{prefix}.lm_head" if prefix else "lm_head", + ) + + self.logits_processor = LogitsProcessor(self._backbone_config.vocab_size) + + # Audio continuation token ID — resolved lazily from tokenizer. + # This is the <|audio|> token that serves as the LM-level continuation + # marker during audio generation (equivalent to v2's audio_token_id). + self._audio_continuation_id: int | None = None + self._eos_token_id: int | None = None + self._resolved_tokens = False + + # Per-request audio state keyed by batch row index. + # Reset per slot via _slot_output_len tracking (same pattern as v2). + self._audio_state: dict[int, dict[str, Any]] = {} + self._slot_output_len: dict[int, int] = {} + self._last_logits_hidden: torch.Tensor | None = None + self._last_step_input_ids: torch.Tensor | None = None + self._last_step_query_start_loc: torch.Tensor | None = None + self._last_first_audio_after_start: torch.Tensor | None = None + self._last_audio_codes: torch.Tensor | None = None + self._postprocess_cursor: int = 0 + + # Pre-allocated decode-step audio feedback buffers (CUDA-graph safe). + # Populated by sample(), read by forward() via torch.where (no dict). + max_bs = 64 # safe upper bound; will grow if needed + self._decode_last_codes = torch.zeros(max_bs, self.num_codebooks, dtype=torch.long) + self._decode_has_codes = torch.zeros(max_bs, dtype=torch.bool) + + # PrefixCache opt-outs (mirror qwen3_tts pattern): + # 1. The talker only consumes the last token's hidden state, so the + # runner can skip the per-step full hidden-state GPU->CPU merge + # that PrefixCache otherwise does. + # 2. Per-step ``codes.audio`` rows stay GPU-resident; defer the CPU + # write of the prefix-cache mm-output copy to request finish so + # the per-step bookkeeping does not block batching. Stage 0 can + # then set ``enable_prefix_caching: true`` without the regression + # observed in qwen3_tts (#3665). + self.requires_full_prefix_cached_hidden_states = False + self.deferred_prefix_cache_mm_keys = {"codes.audio"} + + def _resolve_token_ids(self) -> None: + """Resolve <|audio|> and eos token IDs. + + Prefers config's pre-resolved IDs (from ``resolve_special_tokens()``), + falls back to loading the HF tokenizer directly. + """ + if self._resolved_tokens: + return + self._resolved_tokens = True + + # Try config first (populated by resolve_special_tokens or from_pretrained) + cfg_audio = getattr(self.config, "audio_continuation_id", None) + cfg_eos = getattr(self.config, "eos_token_id", None) + if cfg_audio is not None: + self._audio_continuation_id = int(cfg_audio) + if cfg_eos is not None: + self._eos_token_id = int(cfg_eos) + + if self._audio_continuation_id is not None: + logger.info( + "Resolved v3 token IDs from config: audio_continuation=%s, eos=%s", + self._audio_continuation_id, + self._eos_token_id, + ) + return + + # Fallback: load tokenizer directly + model_path = getattr(self.vllm_config.model_config, "model", None) + if model_path is None: + return + try: + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) + vocab = dict(tokenizer.get_added_vocab()) + if "<|audio|>" in vocab: + self._audio_continuation_id = vocab["<|audio|>"] + if hasattr(tokenizer, "eos_token_id") and tokenizer.eos_token_id is not None: + self._eos_token_id = int(tokenizer.eos_token_id) + logger.info( + "Resolved v3 token IDs from tokenizer: audio_continuation=%s, eos=%s", + self._audio_continuation_id, + self._eos_token_id, + ) + except Exception as exc: + logger.warning("Failed to resolve token IDs from tokenizer: %s", exc) + + # ------------------------------------------------------------------ forward + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: Any | None = None, + inputs_embeds: torch.Tensor | None = None, + **kwargs: Any, + ) -> torch.Tensor: + if inputs_embeds is None: + # Mask -100 placeholders to 0 before embedding. Use torch.where + # (no Python data-dependent branch) so this is CUDA-graph safe. + safe_ids = torch.where(input_ids < 0, torch.zeros_like(input_ids), input_ids) + hidden_states = self.model.embed_tokens(safe_ids) + else: + hidden_states = inputs_embeds + + if input_ids is not None: + self._last_step_input_ids = input_ids + + # Stash query_start_loc for _apply_audio_mode_bias + try: + from vllm.forward_context import get_forward_context + + attn_metadata = get_forward_context().attn_metadata + if isinstance(attn_metadata, dict) and attn_metadata: + attn = next(iter(attn_metadata.values())) + else: + attn = attn_metadata + qsl = getattr(attn, "query_start_loc", None) + if isinstance(qsl, torch.Tensor): + self._last_step_query_start_loc = qsl.detach().clone() + else: + self._last_step_query_start_loc = None + except Exception: + self._last_step_query_start_loc = None + + # Prefill-only operations: ref audio substitution and audio feedback + # require Python dict/list ops that break CUDA graph capture. + # Detect prefill (sequence length > batch size heuristic) vs decode. + is_prefill = input_ids is not None and inputs_embeds is None and int(input_ids.numel()) > 1 + if is_prefill: + # Voice clone: replace -100 placeholder positions with ref audio embeddings + info_dicts = kwargs.get("model_intermediate_buffer") + if info_dicts is None: + info_dicts = kwargs.get("runtime_additional_information") + hidden_states = self._apply_ref_audio_substitution(hidden_states, input_ids, info_dicts) + + # Audio feedback at decode: replace continuation token embeddings + if input_ids is not None and inputs_embeds is None: + hidden_states = self._apply_audio_feedback(hidden_states, input_ids) + + residual: torch.Tensor | None = None + for layer in self.model.layers: + hidden_states, residual = layer(positions, hidden_states, residual) + + norm_out = self.model.norm(hidden_states, residual) + if isinstance(norm_out, tuple): + norm_out = norm_out[0] + return norm_out + + def compute_logits(self, hidden_states: torch.Tensor, sampling_metadata: Any = None) -> torch.Tensor: + self._last_logits_hidden = hidden_states + return self.logits_processor(self.lm_head, hidden_states, sampling_metadata) + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + safe_ids = input_ids + if input_ids is not None and (input_ids < 0).any(): + safe_ids = torch.where(input_ids < 0, torch.zeros_like(input_ids), input_ids) + text_embed = self.model.embed_tokens(safe_ids) + return self._apply_audio_feedback(text_embed, input_ids) + + # ------------------------------------------------------------------ ref audio substitution + def _apply_ref_audio_substitution( + self, + hidden_states: torch.Tensor, + input_ids: torch.Tensor, + info_dicts: list[dict[str, Any]] | None, + ) -> torch.Tensor: + """Replace -100 placeholder positions with fused multi-codebook embeddings + of the delay-pattern-encoded reference audio codes. + + Called at prefill to inject voice clone reference. ``info_dicts`` is a + list of per-request dicts from ``model_intermediate_buffer``, each + containing ``audio_input_ids`` ([T, N] delayed codes) and + ``audio_input_ids_mask`` ([T] bool mask). + """ + if not info_dicts: + return hidden_states + + PLACEHOLDER = -100 + flat_ids = input_ids.reshape(-1) + placeholder_mask = flat_ids == PLACEHOLDER + if not placeholder_mask.any(): + return hidden_states + + # Use query_start_loc to map placeholders to per-request spans + q_start = self._last_step_query_start_loc + if not isinstance(q_start, torch.Tensor) or q_start.numel() < 2: + # Fallback: single-request batch + q_start_list = [0, int(flat_ids.numel())] + else: + q_start_list = q_start.detach().to("cpu").tolist() + + new_hidden: torch.Tensor | None = None + num_requests = min(len(info_dicts), len(q_start_list) - 1) + + for i in range(num_requests): + info = info_dicts[i] + if not isinstance(info, dict): + continue + + codes = info.get("audio_input_ids") + mask = info.get("audio_input_ids_mask") + + # Handle msgspec serialization (may be list-wrapped) + if isinstance(codes, list): + codes = codes[0] if codes else None + if isinstance(mask, list): + mask = mask[0] if mask else None + if not isinstance(codes, torch.Tensor): + continue + + # codes shape: [T, num_codebooks] delayed reference codes + if codes.ndim == 3: + codes = codes[0] + if codes.ndim != 2: + continue + + if isinstance(mask, torch.Tensor): + if mask.ndim == 2: + mask = mask[0] + codes = codes[mask.to(dtype=torch.bool)] + + if codes.numel() == 0: + continue + + # Find placeholder positions in this request's span + s = int(q_start_list[i]) + e = int(q_start_list[i + 1]) + if e - s <= 1: + continue # Decode step, skip + + span_mask = placeholder_mask[s:e] + placeholders = span_mask.nonzero(as_tuple=True)[0] + n_codes = int(codes.shape[0]) + + if int(placeholders.numel()) < n_codes: + continue # Mismatch + + # Embed delayed codes via fused multi-codebook embedding + target = placeholders[:n_codes] + s + codes_device = codes.to(device=hidden_states.device, dtype=torch.long) + embeds = self.multimodal_embedding(codes_device) # [n_codes, hidden] + + if new_hidden is None: + new_hidden = hidden_states.clone() + flat_hidden = new_hidden.reshape(-1, new_hidden.shape[-1]) + flat_hidden[target] = embeds.to(new_hidden.dtype) + + return new_hidden if new_hidden is not None else hidden_states + + # ------------------------------------------------------------------ audio feedback + def _apply_audio_feedback(self, hidden_states: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor: + """Replace decode-step embeddings with audio feedback from pre-allocated buffers. + + CUDA-graph safe: reads from pre-allocated _decode_last_codes and + _decode_has_codes tensors using torch.where (no Python dict lookup). + The buffers are populated by sample() after each step. + + For decode steps (1 token per request): position i maps to row i. + For prefill: audio feedback is not needed (ref audio substitution + handles the prefill path separately). + """ + bs = hidden_states.shape[0] + + # Ensure buffers are on the right device and large enough + if self._decode_last_codes.device != hidden_states.device: + self._decode_last_codes = self._decode_last_codes.to(hidden_states.device) + self._decode_has_codes = self._decode_has_codes.to(hidden_states.device) + if bs > self._decode_last_codes.shape[0]: + new_size = max(bs, self._decode_last_codes.shape[0] * 2) + self._decode_last_codes = torch.zeros( + new_size, self.num_codebooks, dtype=torch.long, device=hidden_states.device + ) + self._decode_has_codes = torch.zeros(new_size, dtype=torch.bool, device=hidden_states.device) + + # Compute audio embeddings from last_codes for ALL rows (graph-safe) + codes_slice = self._decode_last_codes[:bs] # [bs, N] + has_codes = self._decode_has_codes[:bs].unsqueeze(-1) # [bs, 1] + audio_embeds = self.multimodal_embedding(codes_slice) # [bs, D] + audio_embeds = audio_embeds.to(dtype=hidden_states.dtype) + + # Select: where has_codes, use audio embed; else keep text embed + return torch.where(has_codes, audio_embeds, hidden_states) + + # ------------------------------------------------------------------ sampling + def sample(self, logits: torch.Tensor, sampling_metadata: Any) -> Any: + """Model-owned sampler with delay-pattern audio dispatch. + + Mirrors v2's pattern: bias LM logits to force audio continuation, + sample multi-codebook codes via the fused head, apply delay pattern, + and accumulate per-request state. + """ + self._resolve_token_ids() + + sampler = getattr(self, "_stock_sampler", None) + if sampler is None: + from vllm.v1.sample.sampler import Sampler + + sampler = Sampler() + self._stock_sampler = sampler + + audio_id = self._audio_continuation_id + num_codebooks = self.num_codebooks + + # Slot reuse detection: check output_token_ids length for each row. + # If a slot's output length decreased (fresh request reusing a finished + # slot), drop stale _audio_state for that row. + output_ids = getattr(sampling_metadata, "output_token_ids", None) + if output_ids is not None: + for i in range(len(output_ids)): + cur_len = len(output_ids[i]) if output_ids[i] else 0 + prev_len = self._slot_output_len.get(i, 0) + if cur_len < prev_len: + # Fresh request reusing this slot — clear stale state + self._audio_state.pop(i, None) + self._slot_output_len[i] = 0 + self._slot_output_len[i] = cur_len + + # Bias LM logits for audio continuation + self._apply_audio_mode_bias(logits, sampling_metadata) + sampler_output = sampler(logits=logits, sampling_metadata=sampling_metadata) + + hidden = self._last_logits_hidden + self._last_logits_hidden = None + if hidden is None or audio_id is None: + self._last_audio_codes = None + return sampler_output + + sampled = getattr(sampler_output, "sampled_token_ids", None) + if sampled is None: + self._last_audio_codes = None + return sampler_output + sampled_flat = sampled.reshape(-1) + if int(sampled_flat.numel()) != int(hidden.shape[0]): + self._last_audio_codes = None + return sampler_output + + is_audio = sampled_flat == audio_id + + # Handle first-after-<|audio|> transition: initialize state but do NOT + # skip sampling. Unlike v2 (which seeds an all-BOC frame and skips the + # first audio step), v3/sglang samples codebook 0 on the very first + # step with delay_count=0 (codebooks 1-7 masked to BOC). + first_after_start = self._last_first_audio_after_start + self._last_first_audio_after_start = None + + if isinstance(first_after_start, torch.Tensor) and first_after_start.numel() == is_audio.shape[0]: + first_after_start = first_after_start.to(is_audio.device) + init_rows = first_after_start & is_audio + if bool(init_rows.any()): + boc_frame = torch.full((num_codebooks,), BOC_ID, dtype=torch.long, device=hidden.device) + for bi in torch.nonzero(init_rows, as_tuple=False).reshape(-1).tolist(): + bi = int(bi) + self._slot_output_len[bi] = 0 + self._audio_state[bi] = { + "num_delay": 0, + "num_remaining_delays": None, + "audio_out_ids": None, + "last_codes": boc_frame.clone(), + "should_terminate": False, + } + # Update CUDA-graph-safe feedback buffers + self._decode_last_codes[bi] = boc_frame.to(self._decode_last_codes.device) + self._decode_has_codes[bi] = True + # Do NOT remove init_rows from is_audio — let them be sampled + + if not bool(is_audio.any()): + self._last_audio_codes = None + return sampler_output + + audio_row_indices = torch.nonzero(is_audio, as_tuple=False).reshape(-1).tolist() + + # Per-codebook logits at audio positions + cb_logits = self._audio_codebook_logits(hidden, is_audio) + + # Apply delay pattern masking BEFORE sampling + self._apply_delay_pattern_masking(cb_logits, audio_row_indices) + + # Sample per-codebook + cb_logits_2d = cb_logits.reshape(-1, cb_logits.shape[-1]) + codes_2d = self._sample_audio_codes(cb_logits_2d) + codes_flat = codes_2d.view(cb_logits.shape[0], cb_logits.shape[1]).to(torch.long) + + # Update delay pattern state + eos_stream = EOC_ID + bos = BOC_ID + new_codes_flat: list[torch.Tensor] = [] + for local_i, batch_i in enumerate(audio_row_indices): + state = self._audio_state.setdefault( + int(batch_i), + { + "num_delay": 0, + "num_remaining_delays": None, + "audio_out_ids": None, + "last_codes": torch.full((num_codebooks,), bos, dtype=torch.long, device=hidden.device), + "should_terminate": False, + }, + ) + num_delay: int = state["num_delay"] + num_remaining_delays: int | None = state["num_remaining_delays"] + this_codes = codes_flat[local_i].clone() + + # Leading delay-pattern BOS pad + if num_delay + 1 < num_codebooks: + this_codes[num_delay + 1 :] = bos + num_delay += 1 + + # Trailing eos ramp-down + if num_remaining_delays is not None: + this_codes[: num_codebooks - num_remaining_delays] = eos_stream + num_remaining_delays -= 1 + else: + eos_positions = (this_codes == eos_stream).nonzero(as_tuple=False).reshape(-1) + if eos_positions.numel() > 0: + last_eos_idx = int(eos_positions[-1].item()) + this_codes[: last_eos_idx + 1] = eos_stream + num_remaining_delays = num_codebooks - last_eos_idx - 1 + + if num_remaining_delays is not None and num_remaining_delays <= 0: + # Ramp-down complete — terminate + state["num_delay"] = 0 + state["num_remaining_delays"] = None + state["should_terminate"] = True + new_codes_flat.append(torch.full_like(this_codes, -1)) + continue + + state["num_delay"] = num_delay + state["num_remaining_delays"] = num_remaining_delays + state["last_codes"] = this_codes.clone() + # Update CUDA-graph-safe feedback buffers + self._decode_last_codes[int(batch_i)] = this_codes.to(self._decode_last_codes.device) + self._decode_has_codes[int(batch_i)] = True + if state["audio_out_ids"] is None: + state["audio_out_ids"] = this_codes.unsqueeze(-1).clone() + else: + state["audio_out_ids"] = torch.cat([state["audio_out_ids"], this_codes.unsqueeze(-1)], dim=-1) + new_codes_flat.append(this_codes) + + # Build full codes tensor [batch_size, num_codebooks] + # -1 marks "no audio code at this position" + codes_full = torch.full( + (int(sampled_flat.numel()), num_codebooks), + -1, + dtype=torch.long, + device=hidden.device, + ) + if new_codes_flat: + stacked = torch.stack(new_codes_flat, dim=0).to(hidden.device) + codes_full[is_audio] = stacked + + self._last_audio_codes = codes_full + self._postprocess_cursor = 0 + return sampler_output + + # ------------------------------------------------------------------ postprocess + def postprocess( + self, + hidden_states_slice: torch.Tensor, + multimodal_outputs: Any = None, + **req_infos: Any, + ) -> dict[str, Any]: + """Publish per-request audio codes into model_intermediate_buffer. + + Called once per request in batch order. Indexes _last_audio_codes + by a running cursor (one row per request per step). + """ + _ = multimodal_outputs + codes_full = self._last_audio_codes + if codes_full is None: + return {} + + cursor = int(self._postprocess_cursor) + if cursor >= int(codes_full.shape[0]): + self._postprocess_cursor = 0 + return {} + slice_codes = codes_full[cursor : cursor + 1] + self._postprocess_cursor = cursor + 1 + + # Drop placeholder rows (-1) + audio_rows = slice_codes[:, 0] >= 0 + if not bool(audio_rows.any()): + return {} + new_codes = slice_codes[audio_rows].to(torch.int32) + return {"codes": {"audio": new_codes}} + + # ------------------------------------------------------------------ helpers + def _audio_codebook_logits(self, hidden_states: torch.Tensor, audio_mask: torch.Tensor) -> torch.Tensor: + mask = audio_mask.reshape(-1).to(hidden_states.device) + hidden_flat = hidden_states.reshape(-1, hidden_states.shape[-1]) + if not mask.any(): + return torch.empty( + (0, self.num_codebooks, self.codebook_size), + dtype=hidden_states.dtype, + device=hidden_states.device, + ) + return self.modality_head.generate(hidden_flat[mask]) + + def _apply_delay_pattern_masking(self, cb_logits: torch.Tensor, audio_row_indices: list[int]) -> None: + """Mask per-codebook logits according to delay pattern state, in-place. + + During delay phase: codebooks beyond delay_count only allow BOC. + During ramp-down: locked codebooks only allow EOC. + Normal generation: BOC disallowed; only cb0 allows EOC. + """ + bos_pre = BOC_ID + eos_pre = EOC_ID + num_codebooks = self.num_codebooks + for local_i, batch_i in enumerate(audio_row_indices): + state = self._audio_state.get(int(batch_i)) + num_delay = int(state["num_delay"]) if state else 0 + num_rem = state.get("num_remaining_delays") if state else None + + if num_rem is not None: + lock_until = num_codebooks - int(num_rem) + for q in range(num_codebooks): + row = cb_logits[local_i, q] + if q < lock_until: + mask = torch.full_like(row, float("-inf")) + mask[eos_pre] = row[eos_pre] + cb_logits[local_i, q] = mask + else: + cb_logits[local_i, q, bos_pre] = float("-inf") + cb_logits[local_i, q, eos_pre] = float("-inf") + else: + for q in range(num_codebooks): + row = cb_logits[local_i, q] + if q > num_delay: + mask = torch.full_like(row, float("-inf")) + mask[bos_pre] = row[bos_pre] + cb_logits[local_i, q] = mask + else: + cb_logits[local_i, q, bos_pre] = float("-inf") + if q != 0: + cb_logits[local_i, q, eos_pre] = float("-inf") + + def _sample_audio_codes(self, logits_2d: torch.Tensor) -> torch.Tensor: + """Replicate upstream sampling: temperature → top-k → top-p → multinomial.""" + x = logits_2d.float() + top_k = 50 + top_p = 0.95 + if 0 < top_k < x.shape[-1]: + kth = x.topk(top_k, dim=-1).values[..., -1:] + x = x.masked_fill(x < kth, float("-inf")) + if 0.0 < top_p < 1.0: + sorted_logits, sorted_idx = x.sort(dim=-1, descending=True) + cumprobs = sorted_logits.softmax(dim=-1).cumsum(dim=-1) + sorted_mask = cumprobs > top_p + sorted_mask[..., 1:] = sorted_mask[..., :-1].clone() + sorted_mask[..., 0] = False + mask = torch.zeros_like(x, dtype=torch.bool) + mask.scatter_(-1, sorted_idx, sorted_mask) + x = x.masked_fill(mask, float("-inf")) + # Detect all-masked rows BEFORE softmax (softmax of all-inf yields NaN). + has_finite = torch.isfinite(x).any(dim=-1) + all_masked = ~has_finite + if all_masked.any(): + # For all-masked rows, fall back to argmax (picks least-negative). + fallback = x.argmax(dim=-1) + if has_finite.any(): + probs = x[has_finite].softmax(dim=-1) + sampled = torch.multinomial(probs, num_samples=1).squeeze(-1) + result = fallback.clone() + result[has_finite] = sampled + return result + return fallback + probs = x.softmax(dim=-1) + return torch.multinomial(probs, num_samples=1).squeeze(-1) + + def _apply_audio_mode_bias(self, logits: torch.Tensor, sampling_metadata: Any) -> None: + """Detect <|audio|> transition, force continuation, force eos at ramp-down. + + Mirrors v2's _apply_audio_mode_bias: walks per-request to find the + previous token, and if it was <|audio|> or the continuation token, + forces the next emit to <|audio|>. On ramp-down completion, forces eos. + """ + if logits is None or logits.ndim != 2: + return + + audio_id = self._audio_continuation_id + eos_id = self._eos_token_id + if audio_id is None: + return + + num_rows = int(logits.shape[0]) + prompt_ids = getattr(sampling_metadata, "prompt_token_ids", None) + output_ids = getattr(sampling_metadata, "output_token_ids", None) + + # Fallback prev-token source from stashed input_ids + stash_ids = self._last_step_input_ids + stash_tail: list[int] | None = None + if isinstance(stash_ids, torch.Tensor) and stash_ids.numel() > 0: + q_start = self._last_step_query_start_loc + if isinstance(q_start, torch.Tensor) and int(q_start.numel()) == num_rows + 1: + q_start_cpu = q_start.detach().to("cpu").tolist() + tail_idx = [max(0, int(q_start_cpu[i + 1]) - 1) for i in range(num_rows)] + flat_ids = stash_ids.detach().to("cpu").tolist() + stash_tail = [int(flat_ids[idx]) if idx < len(flat_ids) else -1 for idx in tail_idx] + elif int(stash_ids.numel()) >= num_rows: + stash_tail = stash_ids[-num_rows:].detach().to("cpu").tolist() + + first_after_start = torch.zeros(num_rows, dtype=torch.bool, device=logits.device) + + for i in range(num_rows): + prev: int | None = None + if output_ids is not None and i < len(output_ids): + hist = output_ids[i] + if hist: + prev = int(hist[-1]) + if prev is None and prompt_ids is not None: + try: + p_i = prompt_ids[i] + if hasattr(p_i, "tolist"): + p_i = p_i.tolist() + if p_i: + prev = int(p_i[-1]) + except (IndexError, TypeError): + prev = None + if prev is None and stash_tail is not None and i < len(stash_tail): + prev = int(stash_tail[i]) + if prev is None: + continue + + # Only bias if previous token was <|audio|> (the continuation token) + if prev != audio_id: + continue + + # Check if this is the FIRST step after <|audio|> appears + # (i.e., transitioning from prompt to audio generation) + audio_state = self._audio_state.get(i) + if audio_state is None or audio_state.get("should_terminate"): + # No state yet, or stale state from a finished prior request + # reusing this slot — treat as first audio step + self._audio_state.pop(i, None) + # Clear CUDA-graph feedback buffers for this slot + self._decode_has_codes[i] = False + first_after_start[i] = True + + # Check for ramp-down termination + should_terminate = bool(isinstance(audio_state, dict) and audio_state.get("should_terminate")) + if should_terminate and eos_id is not None and 0 <= eos_id < int(logits.shape[-1]): + row = logits[i] + mask = torch.full_like(row, float("-inf")) + mask[eos_id] = row[eos_id] + logits[i].copy_(mask) + audio_state["should_terminate"] = False + continue + + # Force audio continuation token + row = logits[i] + mask = torch.full_like(row, float("-inf")) + if 0 <= audio_id < row.shape[-1]: + mask[audio_id] = row[audio_id] + logits[i].copy_(mask) + + self._last_first_audio_after_start = first_after_start + + # ------------------------------------------------------------------ omni output + 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") + if info_dicts is None: + info_dicts = [] + + audio_codes_list: list[torch.Tensor] = [] + any_nonempty = False + for info in info_dicts: + ac: torch.Tensor | None = None + if isinstance(info, dict): + codes_field = info.get("codes") + if isinstance(codes_field, dict): + ac = codes_field.get("audio") + else: + ac = info.get("audio_codes") + if isinstance(ac, torch.Tensor) and ac.numel() > 0: + audio_codes_list.append(ac) + any_nonempty = True + else: + audio_codes_list.append(torch.empty(0, dtype=torch.long)) + + if any_nonempty: + return OmniOutput( + text_hidden_states=hidden, + multimodal_outputs={"codes": {"audio": audio_codes_list}}, + ) + return OmniOutput(text_hidden_states=hidden, multimodal_outputs=None) + + # ------------------------------------------------------------------ weight loading + + # Per-layer suffixes from the actual V3 checkpoint (results/higgs_v3_checkpoint_analysis.txt) + _V3_LAYER_SUFFIXES = ( + "input_layernorm.weight", + "mlp.down_proj.weight", + "mlp.gate_proj.weight", + "mlp.up_proj.weight", + "post_attention_layernorm.weight", + "self_attn.k_norm.weight", + "self_attn.k_proj.weight", + "self_attn.o_proj.weight", + "self_attn.q_norm.weight", + "self_attn.q_proj.weight", + "self_attn.v_proj.weight", + ) + + @classmethod + def _build_required_keys(cls, num_layers: int) -> set[str]: + """Build the exact set of required V3 checkpoint keys.""" + keys = { + "tied.embedding.text_embedding.weight", + "body.norm.weight", + f"{_MODALITY_EMBEDDING_PREFIX}weight", + } + for i in range(num_layers): + for suffix in cls._V3_LAYER_SUFFIXES: + keys.add(f"body.layers.{i}.{suffix}") + return keys + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + backbone_weights: list[tuple[str, torch.Tensor]] = [] + loaded_params: set[str] = set() + own_params = dict(self.named_parameters()) + seen_checkpoint_keys: set[str] = set() + + for name, tensor in weights: + seen_checkpoint_keys.add(name) + + mapped = self._map_weight_name(name) + if mapped is None: + continue + + if mapped.startswith("model.") or mapped.startswith("lm_head."): + backbone_weights.append((mapped, tensor)) + elif mapped in own_params: + param = own_params[mapped] + if param.shape != tensor.shape: + raise ValueError( + f"Shape mismatch for {mapped}: expected {param.shape}, " + f"got {tensor.shape} (checkpoint key: {name})" + ) + param.data.copy_(tensor.to(param.dtype)) + loaded_params.add(mapped) + + if backbone_weights: + backbone_module = _BackboneWrapper(self.model, self.lm_head, self._backbone_config) + loaded = backbone_module.load_weights(iter(backbone_weights)) + loaded_params.update(loaded) + + # Resolve special token IDs from the tokenizer + model_path = getattr(self.vllm_config.model_config, "model", None) + if model_path: + self.config.resolve_special_tokens(model_path) + self._resolve_token_ids() + + # Verify every required checkpoint key was seen. + num_layers = int(self._backbone_config.num_hidden_layers) + required = self._build_required_keys(num_layers) + missing = required - seen_checkpoint_keys + if missing: + raise RuntimeError( + f"HiggsAudioV3Talker: {len(missing)} required checkpoint keys missing: {sorted(missing)[:5]}..." + ) + + logger.info( + "HiggsAudioV3Talker: loaded %d params, modality_embedding=%s, tied=%s", + len(loaded_params), + tuple(self.multimodal_embedding.weight.shape), + self.tie_modality, + ) + return loaded_params + + def _map_weight_name(self, name: str) -> str | None: + if name.startswith(_CODEC_PREFIX): + return None + if name.startswith(_MODALITY_HEAD_PREFIX): + if self.tie_modality: + return None + return name.replace(_MODALITY_HEAD_PREFIX, "modality_head.") + if name.startswith(_MODALITY_EMBEDDING_PREFIX): + return name.replace(_MODALITY_EMBEDDING_PREFIX, "multimodal_embedding.") + for ckpt_prefix, model_prefix in _BACKBONE_PREFIX_MAP.items(): + if name.startswith(ckpt_prefix): + return name.replace(ckpt_prefix, model_prefix, 1) + # Reject unexpected non-codec Higgs checkpoint keys + raise ValueError( + f"Unexpected checkpoint key with no known mapping: {name!r}. " + f"Known prefixes: {list(_BACKBONE_PREFIX_MAP.keys())}, " + f"{_MODALITY_EMBEDDING_PREFIX!r}, {_MODALITY_HEAD_PREFIX!r}, {_CODEC_PREFIX!r}" + ) + + +class _BackboneWrapper(nn.Module): + """Wrapper to use AutoWeightsLoader for Qwen3 backbone.""" + + packed_modules_mapping = { + "qkv_proj": ["q_proj", "k_proj", "v_proj"], + "gate_up_proj": ["gate_proj", "up_proj"], + } + + def __init__(self, model, lm_head, config): + super().__init__() + self.model = model + self.lm_head = lm_head + self.config = config + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + from vllm.model_executor.models.utils import AutoWeightsLoader + + skip = ["lm_head."] if getattr(self.config, "tie_word_embeddings", False) else None + loader = AutoWeightsLoader(self, skip_prefixes=skip) + return loader.load_weights(weights) diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_tokenizer.py b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_tokenizer.py new file mode 100644 index 000000000..8f881564a --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_tokenizer.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Prompt builder for higgs-audio v3 TTS. + +Prompt formats: + Zero-shot: + <|tts|> <|text|> {text tokens} <|audio|> + Voice clone (no ref text): + <|tts|> <|ref_audio|> [-100]×N <|text|> {text tokens} <|audio|> + Voice clone (with ref text): + <|tts|> <|ref_text|> {ref text tokens} <|ref_audio|> [-100]×N <|text|> {text tokens} <|audio|> + +-100 placeholders are replaced at prefill time with fused multi-codebook +embeddings of the delay-pattern-encoded reference audio codes. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import torch + +__all__ = [ + "HiggsAudioV3TokenizerAdapter", + "AUDIO_PLACEHOLDER_ID", + "encode_reference_audio", + "apply_delay_pattern", +] + +AUDIO_PLACEHOLDER_ID = -100 + +_REQUIRED_SPECIALS: tuple[str, ...] = ( + "<|tts|>", + "<|text|>", + "<|audio|>", +) + +# Codec stream special token IDs (inside the codebook vocab, not the LM vocab) +BOC_ID = 1024 +EOC_ID = 1025 + + +def apply_delay_pattern(codes_tn: torch.Tensor) -> torch.Tensor: + """Apply MusicGen-style delay pattern to raw codes. + + Input: [T, N] raw codes (T frames, N codebooks). + Output: [T + N - 1, N] delayed codes with BOC/EOC padding. + + Codebook c is delayed by c positions: rows 0..c-1 get BOC, + rows c..c+T-1 get real codes, rows c+T..T+N-2 get EOC. + """ + T, N = codes_tn.shape + L = T + N - 1 + delayed = torch.full((L, N), EOC_ID, dtype=codes_tn.dtype) + for c in range(N): + delayed[:c, c] = BOC_ID + delayed[c : c + T, c] = codes_tn[:, c] + return delayed + + +def encode_reference_audio( + wav: np.ndarray, + sr: int, +) -> torch.Tensor: + """Encode a reference audio clip to codec codes [T, num_codebooks]. + + Uses the same HiggsAudioV2TokenizerModel as v2 (same codec). + Returns raw codes before delay pattern application. + """ + from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_v2_tokenizer import ( + _encode_ref_audio_codes, + ) + + # _encode_ref_audio_codes returns [num_codebooks, T_raw] + codes_qt = _encode_ref_audio_codes(wav, sr) + # Transpose to [T_raw, num_codebooks] + return codes_qt.transpose(0, 1).contiguous() + + +class HiggsAudioV3TokenizerAdapter: + """Wraps the HF tokenizer and builds TTS prompts.""" + + def __init__(self, tokenizer: Any) -> None: + self._tok = tokenizer + vocab = dict(tokenizer.get_added_vocab()) + missing = [t for t in _REQUIRED_SPECIALS if t not in vocab] + if missing: + raise ValueError(f"Tokenizer is missing Higgs TTS v3 specials: {missing}") + self.tts_id: int = vocab["<|tts|>"] + self.text_id: int = vocab["<|text|>"] + self.audio_id: int = vocab["<|audio|>"] + self.ref_audio_id: int | None = vocab.get("<|ref_audio|>") + self.ref_text_id: int | None = vocab.get("<|ref_text|>") + + @property + def tokenizer(self) -> Any: + return self._tok + + def build_prompt( + self, + text: str, + *, + num_ref_tokens: int = 0, + reference_text: str | None = None, + ) -> list[int]: + """Build a TTS prompt. + + Args: + text: Target text to synthesize. + num_ref_tokens: Number of delay-pattern reference code rows. + 0 means zero-shot (no voice clone). + reference_text: Optional transcript of the reference audio. + """ + if not text or not text.strip(): + raise ValueError("Text input must be non-empty for TTS") + if num_ref_tokens < 0: + raise ValueError(f"num_ref_tokens must be >= 0, got {num_ref_tokens}") + + ids: list[int] = [self.tts_id] + + # Voice clone: optional ref text + ref audio placeholders + if reference_text and num_ref_tokens > 0 and self.ref_text_id is not None: + ids.append(self.ref_text_id) + ids.extend(self._tok.encode(reference_text, add_special_tokens=False)) + if num_ref_tokens > 0: + if self.ref_audio_id is None: + raise ValueError("Tokenizer missing <|ref_audio|> for voice clone") + ids.append(self.ref_audio_id) + ids.extend([AUDIO_PLACEHOLDER_ID] * num_ref_tokens) + + ids.append(self.text_id) + ids.extend(self._tok.encode(text, add_special_tokens=False)) + ids.append(self.audio_id) + return ids diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/pipeline.py b/vllm_omni/model_executor/models/higgs_audio_v3/pipeline.py new file mode 100644 index 000000000..2f35c497e --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/pipeline.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""higgs-audio v3 pipeline: Talker (text -> 8-codebook codec) -> Code2Wav (codec -> 24 kHz PCM). + +Two delivery modes are wired here: + +* ``sync_process_input_func`` runs when the deploy YAML has + ``async_chunk: false``. The orchestrator collects the entire Stage-0 emit, + reverts the delay pattern once, and hands a single payload to Stage 1. +* ``async_chunk_process_next_stage_input_func`` runs when the deploy YAML + has ``async_chunk: true``. Stage 0 dispatches per AR step; the streaming + adapter buffers raw delay-pattern rows, slides a window with left context + and right holdback, and emits codec-ready frames per chunk. Stage 1 + trims the overlap on both ends so the client sees a coherent PCM stream. +""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_PROC = "vllm_omni.model_executor.stage_input_processors.higgs_audio_v3" + +HIGGS_AUDIO_V3_PIPELINE = PipelineConfig( + model_type="higgs_multimodal_qwen3", + model_arch="HiggsAudioV3TalkerForConditionalGeneration", + hf_architectures=("HiggsMultimodalQwen3ForConditionalGeneration",), + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="higgs_audio_v3", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + engine_output_type="latent", + # stop_token_ids: the model-owned sampler forces eos at ramp-down + # completion. Safety stops from the actual V3 checkpoint: + # 151643 = <|endoftext|> (eos_token_id from config.json) + # 151671 = <|audio_end|> (audio generation end marker) + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [151643, 151671], + }, + async_chunk_process_next_stage_input_func=f"{_PROC}.talker2code2wav_async_chunk", + ), + StagePipelineConfig( + stage_id=1, + model_stage="code2wav", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="audio", + engine_output_type="audio", + model_arch="HiggsAudioV3Code2WavForConditionalGeneration", + sync_process_input_func=f"{_PROC}.talker2code2wav", + sampling_constraints={"detokenize": True}, + ), + ), +) diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py index efcc42da6..b55fda0a7 100644 --- a/vllm_omni/model_executor/models/registry.py +++ b/vllm_omni/model_executor/models/registry.py @@ -119,6 +119,22 @@ _OMNI_MODELS = { "higgs_audio_v2_code2wav", "HiggsAudioV2Code2WavForConditionalGeneration", ), + ## higgs-audio v3 + "HiggsMultimodalQwen3ForConditionalGeneration": ( + "higgs_audio_v3", + "higgs_audio_v3_talker", + "HiggsAudioV3TalkerForConditionalGeneration", + ), + "HiggsAudioV3TalkerForConditionalGeneration": ( + "higgs_audio_v3", + "higgs_audio_v3_talker", + "HiggsAudioV3TalkerForConditionalGeneration", + ), + "HiggsAudioV3Code2WavForConditionalGeneration": ( + "higgs_audio_v3", + "higgs_audio_v3_code2wav", + "HiggsAudioV3Code2WavForConditionalGeneration", + ), ## mimo_audio "MiMoAudioModel": ( "mimo_audio", diff --git a/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py b/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py new file mode 100644 index 000000000..95fbe74a5 --- /dev/null +++ b/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py @@ -0,0 +1,372 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Stage-input processor for higgs-audio v3: Talker -> Code2Wav. + +Two adapters: + +* ``talker2code2wav`` is the sync (non-async-chunk) adapter that runs once, + after Stage 0 has finished, collecting every audio frame into a single + ``OmniTokensPrompt`` for Stage 1. +* ``talker2code2wav_async_chunk`` is the streaming adapter invoked once per + emitted talker step. It accumulates raw delay-pattern rows per request and + flushes a sliding-window chunk to Stage 1 once enough new frames are + available, holding back ``codec_right_holdback_frames`` trailing frames + so the codec has future context for the emit region's tail samples. Stage 1 + trims ``left_context_size * hop`` samples off the front and + ``right_holdback_size * hop`` off the end before emitting PCM. + +Key difference from v2: BOC/EOC filtering happens AFTER delay pattern reversal +to avoid corrupting valid tail content. +""" + +from __future__ import annotations + +from typing import Any + +import torch +from vllm.logger import init_logger + +from vllm_omni.data_entry_keys import ( + CodesStruct, + MetaStruct, + OmniPayload, + OmniPayloadStruct, +) + +__all__ = ["talker2code2wav", "talker2code2wav_async_chunk"] + +logger = init_logger(__name__) + +_NUM_CODEBOOKS = 8 +_NUM_REAL_CODES = 1024 # codes in [0, 1023] are real + +# Defaults for async_chunk streaming when the deploy YAML connector config +# does not override them. Mirrors the qwen3_tts canonical setting at 25 fps +# codec rate (25 frames = ~1.0 s of audio). +_DEFAULT_CODEC_CHUNK_FRAMES = 25 +_DEFAULT_CODEC_LEFT_CONTEXT_FRAMES = 25 +_DEFAULT_CODEC_RIGHT_HOLDBACK_FRAMES = 4 + + +def _revert_delay_pattern(audio_codes_qt: torch.Tensor) -> torch.Tensor: + """Reverse the MusicGen-style delay pattern. + + Input shape: [num_codebooks, seq_len + num_codebooks - 1]. + Output shape: [num_codebooks, seq_len]. + + For each codebook i, extract delayed[i, i : i + seq_len] to remove + the i leading BOC pads and Q-1-i trailing EOC entries. + """ + if audio_codes_qt.ndim != 2: + raise ValueError(f"_revert_delay_pattern expects [Q, T] input; got {tuple(audio_codes_qt.shape)}") + q, t = audio_codes_qt.shape + if q != _NUM_CODEBOOKS: + raise ValueError(f"Expected exactly {_NUM_CODEBOOKS} codebook rows, got {q}. Input shape: [{q}, {t}]") + if t < q: + raise ValueError(f"Not enough frames to revert delay pattern: T={t} < Q={q}") + seq_len = t - q + 1 + out_l = [] + for i in range(q): + out_l.append(audio_codes_qt[i : i + 1, i : seq_len + i]) + return torch.cat(out_l, dim=0) + + +def _filter_real_code_frames(audio_codes_qt: torch.Tensor) -> torch.Tensor: + """Keep only frames where ALL codebook values are in [0, 1023]. + + Input shape: [num_codebooks, num_frames]. + Called AFTER delay pattern reversal. + """ + if audio_codes_qt.numel() == 0: + return audio_codes_qt + # Transpose to [num_frames, num_codebooks] for per-frame filtering + frames = audio_codes_qt.t() + valid = (frames >= 0).all(dim=1) & (frames < _NUM_REAL_CODES).all(dim=1) + return frames[valid].t().contiguous() + + +def talker2code2wav( + source_outputs: list[Any], + prompt: Any = None, + _requires_multimodal_data: bool = False, +) -> list[Any]: + """Sync: collect all talker codes, revert delay pattern, filter, pass to code2wav.""" + from vllm_omni.inputs.data import OmniTokensPrompt + + code2wav_inputs: list[OmniTokensPrompt] = [] + for talker_output in source_outputs: + if not talker_output.finished: + continue + output = talker_output.outputs[0] + mm = output.multimodal_output + mm_codes = mm.get("codes", {}) + + audio_codes = mm_codes.get("audio") + if audio_codes is None or not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=[], + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + continue + + audio_codes = audio_codes.to(torch.long) + if audio_codes.ndim == 1: + if audio_codes.numel() % _NUM_CODEBOOKS != 0: + raise ValueError( + f"flat audio_codes length {audio_codes.numel()} not divisible by num_codebooks={_NUM_CODEBOOKS}" + ) + audio_codes = audio_codes.reshape(-1, _NUM_CODEBOOKS) + + if audio_codes.ndim != 2: + raise ValueError(f"audio_codes must be 1D or 2D; got shape {tuple(audio_codes.shape)}") + + # Validate codebook count + if audio_codes.shape[1] != _NUM_CODEBOOKS: + raise ValueError( + f"Expected {_NUM_CODEBOOKS} codebooks per frame, " + f"got {audio_codes.shape[1]}. Audio codes shape: {tuple(audio_codes.shape)}" + ) + + # Transpose to [Q, T] for delay pattern reversal + codes_qt = audio_codes.transpose(0, 1).contiguous().cpu() + + # Step 1: Revert delay pattern + codes_qt = _revert_delay_pattern(codes_qt) + + # Step 2: Replace out-of-range codes (BOC=1024, EOC=1025, -1) with 0. + # Must use torch.where, NOT clamp: clamp(max=1023) turns 1025→1023 + # which is a valid codec code and decodes to audio artifacts. + # Matches sglang's: torch.where(codes >= codec_vocab, 0, codes) + codes_qt = torch.where( + (codes_qt >= _NUM_REAL_CODES) | (codes_qt < 0), + torch.zeros_like(codes_qt), + codes_qt, + ) + + # Step 3: Trim the last frame. After de-delay, the final frame + # contains residual ramp-down codes (EOC→0 substituted) that + # decode to a brief noise artifact at the end of the audio. + if codes_qt.shape[-1] >= 2: + codes_qt = codes_qt[:, :-1] + + if codes_qt.numel() == 0: + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=[], + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + continue + + # Code2Wav expects codebook-major flat: [Q * num_frames] + codec_codes = codes_qt.reshape(-1).tolist() + + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=codec_codes, + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + return code2wav_inputs + + +# --------------------------------------------------------------------------- +# Async-chunk streaming adapter (sliding window + holdback). +# --------------------------------------------------------------------------- + + +def _extract_last_step_row(pooling_output: OmniPayload) -> torch.Tensor | None: + """Return the talker's emit row for this AR step as a flat ``[Q]`` tensor. + + The talker postprocess emits ``codes.audio`` as ``[1, Q]`` (most often) or + ``[N, Q]`` for multi-active stages. In the async-chunk path + ``pooling_output`` is already request-scoped so the typical shape is + ``[1, Q]``. Returns ``None`` if there is no row to append at this step. + + Rows may carry BOC=1024 in trailing codebooks during the ramp-up phase + (AR steps 0..Q-2). Those are KEPT verbatim — the de-delay window + operation downstream slices them away by construction. + """ + codes = pooling_output.get("codes", {}) if isinstance(pooling_output, dict) else {} + audio_codes = codes.get("audio") if isinstance(codes, dict) else None + if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: + return None + if audio_codes.ndim == 2: + row = audio_codes[-1] + elif audio_codes.ndim == 1: + row = audio_codes + else: + raise ValueError(f"unexpected audio_codes shape for higgs_audio_v3 async_chunk: {tuple(audio_codes.shape)}") + if row.numel() != _NUM_CODEBOOKS: + raise ValueError( + f"talker emit row has {row.numel()} codebooks; expected {_NUM_CODEBOOKS} for higgs_audio_v3 async_chunk." + ) + return row.to(torch.long).reshape(-1) + + +def talker2code2wav_async_chunk( + transfer_manager: Any, + pooling_output: OmniPayload | None, + request: Any, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Streaming adapter: per-step accumulator + sliding-window de-delay flush. + + Algorithm: + + * Append each step's raw delay-pattern row to a per-request buffer. + * Track ``emitted_frames``: how many de-delayed frames' worth of audio + Stage 1 has already emitted for this request. + * With ``N`` accumulated rows we can recover ``N - Q + 1`` de-delayed + frames (the de-delay drops Q-1 leading BOC pads). + * Hold back the trailing ``H = codec_right_holdback_frames`` de-delayed + frames so the codec has future context for the emit region's last + samples. The emit boundary therefore lives at + ``target_emit = max(0, de_delayed_total - H)`` for non-final calls + and ``de_delayed_total`` for the final flush. + * When ``target_emit - emitted_frames >= chunk_size`` (or the request + finishes), build a sliding window spanning + ``[emitted_frames - L, target_emit + H]`` de-delayed frames; that + corresponds to AR rows + ``[emitted_frames - L, target_emit + H + Q - 1]``. Apply de-delay, + replace out-of-range codes with 0, and emit codebook-major flat. + * Stage 1 honors ``meta.left_context_size = L`` (trim front) and + ``meta.right_holdback_size = H`` (trim end). Net new audio per call + is ``(target_emit - emitted_frames) * hop_length`` samples. + """ + request_id = request.external_req_id + finished = bool(is_finished or request.is_finished()) + + # Per-request rolling state. ``code_prompt_token_ids`` is the + # framework-provided buffer; ``higgs_v3_emitted_frames`` tracks how many + # de-delayed frames we have already flushed for this request. + emitted_frames = getattr(transfer_manager, "higgs_v3_emitted_frames", None) + if emitted_frames is None: + emitted_frames = {} + transfer_manager.higgs_v3_emitted_frames = emitted_frames + + if isinstance(pooling_output, dict): + row = _extract_last_step_row(pooling_output) + if row is not None: + transfer_manager.code_prompt_token_ids[request_id].append(row.cpu().tolist()) + elif not finished: + return None + + connector = getattr(transfer_manager, "connector", None) + raw_cfg = getattr(connector, "config", {}) or {} + cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {} + chunk_size = int(cfg.get("codec_chunk_frames", _DEFAULT_CODEC_CHUNK_FRAMES)) + left_context_size_config = int(cfg.get("codec_left_context_frames", _DEFAULT_CODEC_LEFT_CONTEXT_FRAMES)) + right_holdback_size_config = int(cfg.get("codec_right_holdback_frames", _DEFAULT_CODEC_RIGHT_HOLDBACK_FRAMES)) + configured_initial_chunk_size = int(cfg.get("initial_codec_chunk_frames") or 0) + + if ( + chunk_size <= 0 + or left_context_size_config < 0 + or right_holdback_size_config < 0 + or configured_initial_chunk_size < 0 + ): + raise ValueError( + f"Invalid codec chunk config: codec_chunk_frames={chunk_size}, " + f"codec_left_context_frames={left_context_size_config}, " + f"codec_right_holdback_frames={right_holdback_size_config}, " + f"initial_codec_chunk_frames={configured_initial_chunk_size}" + ) + + n_rows = len(transfer_manager.code_prompt_token_ids[request_id]) + de_delayed_total = max(0, n_rows - (_NUM_CODEBOOKS - 1)) + emitted = int(emitted_frames.get(request_id, 0)) + + # Hold back the trailing H frames from the emit boundary unless the + # request is finishing - the final chunk needs no future context. + H = 0 if finished else right_holdback_size_config + target_emit = max(0, de_delayed_total - H) + pending = target_emit - emitted + + # First-chunk fast path for TTFA. + if emitted == 0 and 0 < configured_initial_chunk_size < chunk_size: + target_chunk = configured_initial_chunk_size + else: + target_chunk = chunk_size + + if not finished and pending < target_chunk: + return None + + if pending <= 0: + if finished: + emitted_frames.pop(request_id, None) + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) + return None + + actual_chunk = pending + + desired_left_context = min(left_context_size_config, emitted) + + # Window in AR-row space: span the de-delayed frames + # [emitted - L, target_emit + H) which require AR rows + # [emitted - L, target_emit + H + Q - 1). + window_row_start = emitted - desired_left_context + window_row_end_exclusive = target_emit + H + (_NUM_CODEBOOKS - 1) + if window_row_end_exclusive > n_rows: + # Defensive: pending >= 0 was checked above; this should be unreachable + # for non-final calls. On the final call we cap to n_rows. + if not finished: + return None + window_row_end_exclusive = n_rows + + window_rows = transfer_manager.code_prompt_token_ids[request_id][window_row_start:window_row_end_exclusive] + if len(window_rows) < _NUM_CODEBOOKS: + if finished: + emitted_frames.pop(request_id, None) + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) + return None + + codes_qt = torch.tensor(window_rows, dtype=torch.long).t().contiguous() + de_delayed = _revert_delay_pattern(codes_qt) + # Replace BOC=1024/EOC=1025 (and any negative pads) with 0; matches the + # sync-path substitution. Clamp would turn 1025 into 1023 which is a + # VALID codec code and decodes to audible artifacts. + de_delayed = torch.where( + (de_delayed >= _NUM_REAL_CODES) | (de_delayed < 0), + torch.zeros_like(de_delayed), + de_delayed, + ) + + # On the FINAL chunk only: trim the trailing residual frame (the last + # de-delayed frame still carries EOC-substituted codes from ramp-down + # which decode to a brief noise artifact). Mirrors the sync-path trim. + if finished and window_row_end_exclusive == n_rows and de_delayed.shape[-1] >= 2: + de_delayed = de_delayed[:, :-1] + actual_chunk = max(actual_chunk - 1, 0) + + codec_codes = de_delayed.reshape(-1) + left_context_emitted = desired_left_context + right_holdback_emitted = H + + emitted_frames[request_id] = emitted + actual_chunk + if finished: + emitted_frames.pop(request_id, None) + + meta = MetaStruct( + left_context_size=left_context_emitted, + right_holdback_size=right_holdback_emitted, + finished=torch.tensor(finished, dtype=torch.bool), + ) + return OmniPayloadStruct( + codes=CodesStruct(audio=codec_codes), + meta=meta, + ) From fce0f51089934730621d2917c45ac1cdf9ae0860 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:09:16 -0600 Subject: [PATCH 15/75] [Perf][Higgs-Audio-V3] LRU cache for voice-clone ref-audio encode (#4200) Signed-off-by: Yueqian Lin --- .../entrypoints/openai/serving_speech.py | 8 +- .../models/higgs_audio_v3/ref_audio_cache.py | 111 ++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 vllm_omni/model_executor/models/higgs_audio_v3/ref_audio_cache.py diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 3b5957602..bec890cab 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -1935,10 +1935,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): apply_delay_pattern, encode_reference_audio, ) + from vllm_omni.model_executor.models.higgs_audio_v3.ref_audio_cache import ( + cached_encode_reference_audio, + ) wav_list, sr = await self._resolve_ref_audio(request.ref_audio) wav = np.asarray(wav_list, dtype=np.float32) - ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, int(sr)) + # Cache the codec encode by content hash so identical ref_audio + # payloads skip the encode and reuse the same [num_frames, + # num_codebooks] codes tensor across requests. + ref_codes_raw = await asyncio.to_thread(cached_encode_reference_audio, wav, int(sr), encode_reference_audio) ref_codes_delayed = apply_delay_pattern(ref_codes_raw) prompt_ids = adapter.build_prompt( diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/ref_audio_cache.py b/vllm_omni/model_executor/models/higgs_audio_v3/ref_audio_cache.py new file mode 100644 index 000000000..2a21aafcd --- /dev/null +++ b/vllm_omni/model_executor/models/higgs_audio_v3/ref_audio_cache.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Bounded LRU cache for Higgs Audio v3 reference-audio codec encoding. + +Voice-clone requests that re-send the same ref_audio are otherwise re-encoded +through the DAC encoder on every call. The cache keys by a stable content +hash (size + sample rate + head/mid/tail sentinel bytes) so the same audio +served via different URLs or repeated base64 payloads also hits warm. +Tensors are cloned on get/put so callers and the cache cannot alias storage. +""" + +from __future__ import annotations + +import hashlib +import threading +from collections import OrderedDict +from collections.abc import Callable + +import numpy as np +import torch + +__all__ = ["cached_encode_reference_audio", "cache_stats", "clear_cache"] + +_DEFAULT_MAX_ENTRIES = 128 +_SENTINEL_BYTES = 4096 + +_cache: OrderedDict[str, torch.Tensor] = OrderedDict() +_cache_lock = threading.Lock() +_hits = 0 +_misses = 0 + + +def _content_hash(wav: np.ndarray, sr: int) -> str: + """Stable content hash. Head/mid/tail sentinel keeps cost O(1).""" + raw = wav.tobytes() + n = len(raw) + h = hashlib.sha256() + h.update(n.to_bytes(8, "little")) + h.update(int(sr).to_bytes(4, "little")) + if n == 0: + return h.hexdigest() + if n <= _SENTINEL_BYTES * 3: + h.update(raw) + return h.hexdigest() + h.update(raw[:_SENTINEL_BYTES]) + mid = (n - _SENTINEL_BYTES) // 2 + h.update(raw[mid : mid + _SENTINEL_BYTES]) + h.update(raw[-_SENTINEL_BYTES:]) + return h.hexdigest() + + +def cached_encode_reference_audio( + wav: np.ndarray, + sr: int, + encode_fn: Callable[[np.ndarray, int], torch.Tensor], + max_entries: int = _DEFAULT_MAX_ENTRIES, +) -> torch.Tensor: + """LRU-cached wrapper around ``encode_reference_audio``. + + ``encode_fn`` is the actual codec encoder; passed in so this module + stays decoupled from the tokenizer import (avoids circular imports + during stage-input-processor discovery). + + Returns a CPU tensor clone — never the cache's interior storage. + """ + global _hits, _misses + key = _content_hash(wav, sr) + with _cache_lock: + cached = _cache.get(key) + if cached is not None: + _hits += 1 + _cache.move_to_end(key) + return cached.clone() + + # Miss: encode outside the lock so concurrent misses on different + # refs don't serialize on this single mutex. + codes = encode_fn(wav, sr) + codes_cpu = codes.detach().to("cpu").contiguous() + with _cache_lock: + if key in _cache: + # Concurrent miss winner already stored it; refresh LRU position + # and return that copy to avoid divergent tensors per request. + _cache.move_to_end(key) + _misses += 1 # we still paid the encode cost + return _cache[key].clone() + _cache[key] = codes_cpu + if len(_cache) > max_entries: + _cache.popitem(last=False) + _misses += 1 + return codes_cpu.clone() + + +def cache_stats() -> dict: + """Return (hits, misses, size, hit_rate) — for /metrics-style reporting.""" + with _cache_lock: + total = _hits + _misses + return { + "hits": _hits, + "misses": _misses, + "size": len(_cache), + "hit_rate": (_hits / total) if total > 0 else 0.0, + } + + +def clear_cache() -> None: + """Drop all cached entries. Used by tests.""" + global _hits, _misses + with _cache_lock: + _cache.clear() + _hits = 0 + _misses = 0 From 82cce633a3058b98248863b716ed7e64017bd0ef Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:09:47 -0600 Subject: [PATCH 16/75] [Perf][Higgs-Audio-V3] Turn on Stage-0 prefix caching by default (#4199) Signed-off-by: Yueqian Lin --- vllm_omni/core/prefix_cache.py | 16 +++++++++++----- vllm_omni/deploy/higgs_multimodal_qwen3.yaml | 7 ++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/vllm_omni/core/prefix_cache.py b/vllm_omni/core/prefix_cache.py index 79f0990fd..25acfe8ed 100644 --- a/vllm_omni/core/prefix_cache.py +++ b/vllm_omni/core/prefix_cache.py @@ -391,27 +391,33 @@ class OmniTensorPrefixCache: self, query_start_loc: torch.Tensor, input_batch: InputBatch, - multimodal_outputs: dict, + multimodal_outputs: dict | None, num_scheduled_tokens: dict[str, int], ): """Get the merged multimodal states if hidden state prefix caching is enabled.""" combined_multimodal_outputs = {} + # Talkers that produce multimodal outputs only at decode (e.g. Higgs + # Audio v3's audio codes) have ``multimodal_outputs is None`` at the + # initial prefill step. Treat it as an empty mapping so the merger + # short-circuits cleanly and the cache merge still runs once codes + # start arriving. + mm_outputs = multimodal_outputs if multimodal_outputs is not None else {} # First get the prefix cached tensors that are present in the mm data for mm_key in self.mm_cache_keys: - if mm_key in multimodal_outputs: + if mm_key in mm_outputs: combined_multimodal_outputs[mm_key] = self._get_merged_tensors( query_start_loc=query_start_loc, input_batch=input_batch, cache=self.mm_outputs_cache[mm_key], - hidden_states=multimodal_outputs[mm_key], + hidden_states=mm_outputs[mm_key], num_scheduled_tokens=num_scheduled_tokens, ) # Then, get everything else (passthrough data); first, convert to CPU # tensors similarly to the non prefix cached path, and then populate # the subdicts mapping request IDs -> payload objects - passthrough_keys = set(multimodal_outputs.keys()) - self.mm_cache_keys - passthrough_mm_data = {k: v for k, v in multimodal_outputs.items() if k in passthrough_keys} + passthrough_keys = set(mm_outputs.keys()) - self.mm_cache_keys + passthrough_mm_data = {k: v for k, v in mm_outputs.items() if k in passthrough_keys} mm_cpu = build_mm_cpu(multimodal_outputs=passthrough_mm_data) for mm_key, mm_val in mm_cpu.items(): diff --git a/vllm_omni/deploy/higgs_multimodal_qwen3.yaml b/vllm_omni/deploy/higgs_multimodal_qwen3.yaml index f6742f10a..c7016537b 100644 --- a/vllm_omni/deploy/higgs_multimodal_qwen3.yaml +++ b/vllm_omni/deploy/higgs_multimodal_qwen3.yaml @@ -33,7 +33,12 @@ stages: gpu_memory_utilization: 0.6 enforce_eager: true trust_remote_code: true - enable_prefix_caching: false + # Stage-0 prefix caching is on by default. The talker's HS opt-out and + # deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py) + # keep per-step bookkeeping negligible; any shared text prefix across + # concurrent requests is a net throughput win. Stage 1 (codec decoder) + # has no KV cache and stays disabled. + enable_prefix_caching: true async_scheduling: false max_num_batched_tokens: 4096 max_model_len: 8192 From 923c1d94023f1e2583f823625f221541e07df486 Mon Sep 17 00:00:00 2001 From: yangyonggit Date: Sun, 7 Jun 2026 15:49:41 +0200 Subject: [PATCH 17/75] [Doc] Add Stable-Diffusion-3.5 recipe for 1x RTX A6000 48GB (#2645) (#4052) Signed-off-by: leo.yang --- recipes/README.md | 1 + recipes/StabilityAI/Stable-Diffusion-3.5.md | 139 ++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 recipes/StabilityAI/Stable-Diffusion-3.5.md diff --git a/recipes/README.md b/recipes/README.md index 4648dbd6e..132adfecb 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -50,6 +50,7 @@ recipes/ | [`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-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 | +| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB | | [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB | | [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB | | diff --git a/recipes/StabilityAI/Stable-Diffusion-3.5.md b/recipes/StabilityAI/Stable-Diffusion-3.5.md new file mode 100644 index 000000000..7c8f747aa --- /dev/null +++ b/recipes/StabilityAI/Stable-Diffusion-3.5.md @@ -0,0 +1,139 @@ +# Stable Diffusion 3.5 + +> Text-to-image serving with Stability AI's SD 3.5 family + +## Summary + +- Vendor: Stability AI +- Model: `stabilityai/stable-diffusion-3.5-medium`, `stabilityai/stable-diffusion-3.5-large` +- Task: Text-to-image generation +- Mode: Online serving with the OpenAI-compatible images API +- Maintainer: Community + +## When to use this recipe + +Use this recipe when you want a lightweight, fast text-to-image model that +fits on a single consumer or workstation GPU. SD 3.5-medium requires only +~17 GiB VRAM and generates 1024×1024 images in ~4 seconds, making it one of +the most accessible diffusion models in vLLM-Omni. SD 3.5-large produces +noticeably sharper images at the cost of ~34 GiB VRAM and ~21 seconds per +image. + +## References + +- Model (medium): +- Model (large): +- Related example under `examples/`: + [`examples/online_serving/text_to_image/README.md`](../../examples/online_serving/text_to_image/README.md) +- User guide: + [`docs/user_guide/examples/online_serving/text_to_image.md`](../../docs/user_guide/examples/online_serving/text_to_image.md) + +## Hardware Support + +This recipe documents two CUDA GPU configurations on a single RTX A6000 48 GB, +one for each model variant. Extend it with more hardware sections as community +validation lands. + +## GPU + +### 1x NVIDIA RTX A6000 48GB — stable-diffusion-3.5-medium + +#### Environment + +- OS: Ubuntu 22.04 +- Python: 3.12 +- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0 +- vLLM version: 0.22.0 +- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d + +#### Command + +```bash +vllm serve stabilityai/stable-diffusion-3.5-medium --omni --port 8091 +``` + +#### Verification + +```bash +curl -X POST http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "stabilityai/stable-diffusion-3.5-medium", + "prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus", + "negative_prompt": "blurry, out of focus", + "guidance_scale": 4.5, + "n": 1, + "size": "1024x1024" + }' | python3 -c " +import json, base64, sys +data = json.load(sys.stdin) +with open('output.png', 'wb') as f: + f.write(base64.b64decode(data['data'][0]['b64_json'])) +print('saved output.png') +" +``` + +Or use the example client: + +```bash +python examples/online_serving/text_to_image/openai_chat_client.py \ + --server http://localhost:8091 \ + --prompt "a cute cat sitting on a windowsill" \ + --output output.png +``` + +#### Notes + +- Memory usage: Model weights ~15.6 GiB, peak VRAM ~17.0 GiB (no CFG), ~20.9 GiB with `guidance_scale > 1` (CFG enabled). +- Generation time: ~4 seconds without CFG, ~8 seconds with `guidance_scale=4.5`. +- Model loading time: ~55 seconds (including weight download on first run). +- `negative_prompt` requires `guidance_scale > 1` to take effect; SD3 defaults to 1.0 (no CFG). + +### 1x NVIDIA RTX A6000 48GB — stable-diffusion-3.5-large + +#### Environment + +- OS: Ubuntu 22.04 +- Python: 3.12 +- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0 +- vLLM version: 0.22.0 +- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d + +#### Command + +```bash +vllm serve stabilityai/stable-diffusion-3.5-large --omni --port 8091 +``` + +> **Note**: `stable-diffusion-3.5-large` is a gated model. Accept the license +> on [Hugging Face](https://huggingface.co/stabilityai/stable-diffusion-3.5-large) +> and set `HF_TOKEN` before running. + +#### Verification + +```bash +curl -X POST http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "stabilityai/stable-diffusion-3.5-large", + "prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus", + "negative_prompt": "blurry, out of focus", + "guidance_scale": 4.5, + "n": 1, + "size": "1024x1024" + }' | python3 -c " +import json, base64, sys +data = json.load(sys.stdin) +with open('output.png', 'wb') as f: + f.write(base64.b64decode(data['data'][0]['b64_json'])) +print('saved output.png') +" +``` + +#### Notes + +- Memory usage: Peak VRAM ~31.6 GiB at 1024×1024, ~45.5 GiB at 2048×2048 with `guidance_scale=4.5`. +- Generation time: ~19 seconds at 1024×1024, ~102 seconds at 2048×2048. +- Image quality is noticeably better than medium, with sharper details. +- `negative_prompt` requires `guidance_scale > 1` to take effect; SD3 defaults to 1.0 (no CFG). +- 2048×2048 fits within 48 GB but leaves little headroom (~45.5 GiB used out of 49 GiB). From 4d71a162e6a9efb1a342b38c934d5e52b3323046 Mon Sep 17 00:00:00 2001 From: Optance Date: Sun, 7 Jun 2026 23:01:25 +0800 Subject: [PATCH 18/75] [Bugfix] Fish Speech Gradio hardcoded default voice causes 400 error (#3941) Signed-off-by: nagelanping --- .../text_to_speech/fish_speech/gradio_demo.py | 12 +++++++++--- .../text_to_speech/fish_speech/speech_client.py | 1 - 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/online_serving/text_to_speech/fish_speech/gradio_demo.py b/examples/online_serving/text_to_speech/fish_speech/gradio_demo.py index 7db4e0441..cd2279476 100644 --- a/examples/online_serving/text_to_speech/fish_speech/gradio_demo.py +++ b/examples/online_serving/text_to_speech/fish_speech/gradio_demo.py @@ -51,13 +51,18 @@ def build_payload( response_format: str = "wav", stream: bool = False, ) -> dict: - """Build the /v1/audio/speech request payload.""" + """Build the /v1/audio/speech request payload. + + Fish Speech has no built-in speakers; omit ``voice`` so the model + generates with a random timbre (controllable via text tags like + ``[cool]``, ``[warm]``, etc.). When ``ref_audio`` is supplied we + enter voice-cloning mode instead. + """ if not text or not text.strip(): raise gr.Error("Please enter text to synthesize.") payload: dict = { "input": text.strip(), - "voice": "default", "response_format": "pcm" if stream else response_format, "stream": stream, } @@ -193,7 +198,8 @@ def build_interface(api_base: str, stream_chunk_seconds: float = 0.5): with gr.Accordion("Voice Cloning (optional)", open=False): gr.Markdown( - "Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice." + "Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice. " + "Leave empty for random timbre; use text tags like `[cool]`, `[warm]`, `[happy]` to steer the output." ) ref_audio = gr.Audio( label="Reference Audio", diff --git a/examples/online_serving/text_to_speech/fish_speech/speech_client.py b/examples/online_serving/text_to_speech/fish_speech/speech_client.py index e3ace37e5..43acffdc7 100644 --- a/examples/online_serving/text_to_speech/fish_speech/speech_client.py +++ b/examples/online_serving/text_to_speech/fish_speech/speech_client.py @@ -39,7 +39,6 @@ def run_tts(args) -> None: payload = { "model": args.model, "input": args.text, - "voice": "default", "response_format": args.response_format, } From 176b00538c48eb97738e1de4666ce7efa469efdf Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sun, 7 Jun 2026 21:40:40 +0530 Subject: [PATCH 19/75] [Model] Support languages added by fine-tuned Qwen3-TTS checkpoints (#4210) Signed-off-by: lokesh --- docs/serving/speech_api.md | 2 +- .../openai_api/test_serving_speech.py | 62 +++++++++++++++++++ .../entrypoints/openai/serving_speech.py | 62 ++++++++++++++----- 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index 61de36938..7fdac15df 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -116,7 +116,7 @@ Content-Type: application/json | `initial_codec_chunk_frames` | integer | null | Per-request initial chunk size override for TTFA tuning. When null, IC is computed dynamically based on server load. | | `stream` | bool | false | Stream raw PCM chunks as they are decoded (requires `response_format="pcm"`) | -**Supported languages:** Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian +**Supported languages:** Only applicable to Qwen3-TTS. Derived from the model configuration (`talker_config.codec_language_id` in the checkpoint's `config.json`), plus `Auto`, which is always accepted. Official Qwen3-TTS checkpoints support: Auto, Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, Italian. #### Voice Clone Parameters (Base task) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 4fcf3418a..a1fd3392a 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -33,6 +33,7 @@ from vllm_omni.entrypoints.openai.protocol.audio import ( SpeechBatchItem, ) from vllm_omni.entrypoints.openai.serving_speech import ( + _TTS_LANGUAGES, OmniOpenAIServingSpeech, _create_wav_header, ) @@ -1257,6 +1258,67 @@ class TestTTSMethods: # Verify speakers are normalized to lowercase assert server.supported_speakers == {"ryan", "vivian", "aiden"} + def test_load_supported_languages_from_config(self, speech_server): + """Languages/dialects from codec_language_id are loaded title-cased; 'Auto' is added.""" + speech_server._tts_model_type = "qwen3_tts" + speech_server.engine_client.model_config = SimpleNamespace( + hf_config=SimpleNamespace( + talker_config=SimpleNamespace( + codec_language_id={"chinese": 2055, "english": 2050, "beijing_dialect": 2074} + ) + ) + ) + assert speech_server._load_supported_languages() == { + "Chinese", + "English", + "Beijing_Dialect", + "Auto", + } + + def test_load_supported_languages_from_dict_config(self, speech_server): + """talker_config provided as a plain dict is handled the same as an object.""" + speech_server._tts_model_type = "qwen3_tts" + speech_server.engine_client.model_config = SimpleNamespace( + hf_config=SimpleNamespace(talker_config={"codec_language_id": {"chinese": 2055, "english": 2050}}) + ) + assert speech_server._load_supported_languages() == {"Chinese", "English", "Auto"} + + def test_validate_language_custom_dialect_accepted(self, speech_server): + """A language present in the model config passes validation, case-insensitively.""" + speech_server.supported_languages = {"Chinese", "English", "Beijing_Dialect", "Auto"} + for language in ("Beijing_Dialect", "beijing_dialect", "English", "english", "Auto", "AUTO"): + req = OpenAICreateSpeechRequest(input="Hello", language=language) + result = speech_server._validate_tts_request(req) + assert result is None or "Invalid language" not in result + # Language is normalized to the title-cased config form. + assert req.language == language.title() + + def test_validate_language_unknown_rejected(self, speech_server): + """A language not in the configured set is rejected.""" + speech_server.supported_languages = {"Chinese", "English", "Auto"} + for language in ("Klingon", "klingon"): + req = OpenAICreateSpeechRequest(input="Hello", language=language) + assert "Invalid language" in speech_server._validate_tts_request(req) + + def test_load_supported_languages_default_when_no_config(self, speech_server): + """Empty/missing codec_language_id on a Qwen3-TTS model falls back to the default list.""" + speech_server._tts_model_type = "qwen3_tts" + speech_server.engine_client.model_config = SimpleNamespace( + hf_config=SimpleNamespace(talker_config=SimpleNamespace(codec_language_id={})) + ) + assert speech_server._load_supported_languages() == _TTS_LANGUAGES + + def test_load_supported_languages_default_on_config_error(self, speech_server): + """If the model config cannot be read, fall back to the default list.""" + speech_server._tts_model_type = "qwen3_tts" + speech_server.engine_client = SimpleNamespace() # no model_config -> AttributeError + assert speech_server._load_supported_languages() == _TTS_LANGUAGES + + def test_load_supported_languages_default_for_non_qwen(self, speech_server): + """Non-qwen3_tts model types get the default language set.""" + speech_server._tts_model_type = None + assert speech_server._load_supported_languages() == _TTS_LANGUAGES + def test_build_tts_params_with_uploaded_voice(self, speech_server, mocker: MockerFixture): """Test _build_tts_params auto-sets ref_audio for uploaded voices (x_vector only).""" speech_server.uploaded_speakers = { diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index bec890cab..1857c6fc8 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -9,6 +9,7 @@ import re import struct import time from collections import OrderedDict +from collections.abc import Mapping from concurrent.futures import ThreadPoolExecutor from http import HTTPStatus from pathlib import Path @@ -102,19 +103,21 @@ _SAMPLING_MAX_TOKENS_TTS_MODEL_TYPES = { "higgs_audio_v2", "higgs_audio_v3", } -_TTS_LANGUAGES: set[str] = { - "Auto", - "Chinese", - "English", - "Japanese", - "Korean", - "German", - "French", - "Russian", - "Portuguese", - "Spanish", - "Italian", -} +_TTS_LANGUAGES = frozenset( + { + "Auto", + "Chinese", + "English", + "Japanese", + "Korean", + "German", + "French", + "Russian", + "Portuguese", + "Spanish", + "Italian", + } +) _REF_AUDIO_MIN_DURATION = 1.0 # seconds _REF_AUDIO_MAX_DURATION = 30.0 # seconds _REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256 @@ -434,6 +437,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Merge built-in speakers into the set initialized by _init_speaker_storage. self.supported_speakers |= self._load_supported_speakers() self.supported_speakers |= set(self.precomputed_speakers) + + self.supported_languages = self._load_supported_languages() + self._tts_tokenizer = None self._voxcpm2_tokenizer = None self._voxcpm2_split_map: dict[int, list[int]] = {} @@ -694,6 +700,26 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return set() + def _load_supported_languages(self) -> frozenset[str]: + """Load supported languages (title-cased) from the model configuration""" + if self._tts_model_type != "qwen3_tts": + return _TTS_LANGUAGES + try: + config = self.engine_client.model_config.hf_config.talker_config + + if isinstance(config, dict): + codec_language_id = config.get("codec_language_id") + else: + codec_language_id = getattr(config, "codec_language_id", None) + + if codec_language_id and isinstance(codec_language_id, Mapping): + return frozenset(str(language).title() for language in codec_language_id) | {"Auto"} + + logger.warning("No codec_language_id found in talker_config; falling back to default languages") + except Exception as e: + logger.warning("Could not load languages from model config: %s", e) + return _TTS_LANGUAGES + def _estimate_ref_code_len(self, ref_audio: object) -> int | None: """Estimate ref_code length from ref_audio waveform without running the codec. @@ -1420,9 +1446,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if not request.input or not request.input.strip(): return "Input text cannot be empty" - # Validate language - if request.language is not None and request.language not in _TTS_LANGUAGES: - return f"Invalid language '{request.language}'. Supported: {', '.join(sorted(_TTS_LANGUAGES))}" + # Validate language (case-insensitive; normalized to the title-cased config form) + if request.language is not None: + request.language = request.language.title() + if request.language not in self.supported_languages: + return ( + f"Invalid language '{request.language}'. Supported: {', '.join(sorted(self.supported_languages))}" + ) # Validate speaker for CustomVoice task if task_type == "CustomVoice": From 29b18ea298dff66392aa33c0f68cae6aeca6da4d Mon Sep 17 00:00:00 2001 From: SYLAR <125541396+lishunyang12@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:53:06 +0800 Subject: [PATCH 20/75] [Quant] ModelOpt FP8 for Wan2.2 & HunyuanVideo-1.5 video-gen (#3305) Signed-off-by: WeiQing Chen --- .../quantization/check_modelopt_fp8_export.py | 247 +++++++++ .../quantize_hunyuanvideo_15_modelopt_fp8.py | 448 +++++++++++++++++ .../quantize_wan2_2_modelopt_fp8.py | 471 ++++++++++++++++++ .../accuracy/test_videogen_modelopt_quant.py | 273 ++++++++++ .../model_loader/diffusers_loader.py | 9 +- .../hunyuan_video_15_transformer.py | 44 +- .../pipeline_hunyuan_video_1_5.py | 4 +- .../hunyuan_video_15_dit_fp8.yaml | 38 ++ .../stage_configs/wan2_2_ti2v_dit_fp8.yaml | 41 ++ vllm_omni/patch.py | 40 ++ 10 files changed, 1607 insertions(+), 8 deletions(-) create mode 100644 examples/quantization/check_modelopt_fp8_export.py create mode 100644 examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py create mode 100644 examples/quantization/quantize_wan2_2_modelopt_fp8.py create mode 100644 tests/e2e/accuracy/test_videogen_modelopt_quant.py create mode 100644 vllm_omni/model_executor/stage_configs/hunyuan_video_15_dit_fp8.yaml create mode 100644 vllm_omni/model_executor/stage_configs/wan2_2_ti2v_dit_fp8.yaml diff --git a/examples/quantization/check_modelopt_fp8_export.py b/examples/quantization/check_modelopt_fp8_export.py new file mode 100644 index 000000000..90d952a8f --- /dev/null +++ b/examples/quantization/check_modelopt_fp8_export.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Verify a ModelOpt FP8 diffusers checkpoint exported by +quantize_wan2_2_modelopt_fp8.py / quantize_hunyuanvideo_15_modelopt_fp8.py. + +Three checks: + A. transformer/config.json has a sane quantization_config block. + B. transformer/*.safetensors contains FP8 (float8_e4m3fn) quantized tensors. + C. transformer disk size is materially smaller than a BF16 baseline. + +Example: + python examples/quantization/check_modelopt_fp8_export.py \\ + --output ./hv15-480p-modelopt-fp8 + + # Optional: compare disk size against a local or HF BF16 baseline. + python examples/quantization/check_modelopt_fp8_export.py \\ + --output ./hv15-480p-modelopt-fp8 \\ + --baseline hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + +SUPPORTED_ALGOS = ("FP8",) + + +def _check_config(transformer_dir: Path) -> tuple[int, str | None]: + """Returns (status, quant_algo). status: 0 pass, 1 fail, 2 warn.""" + cfg_path = transformer_dir / "config.json" + if not cfg_path.exists(): + print(f"[FAIL] {cfg_path} missing.") + return 1, None + + with cfg_path.open(encoding="utf-8") as f: + cfg = json.load(f) + + qc = cfg.get("quantization_config") + if not isinstance(qc, dict): + print(f"[FAIL] No `quantization_config` block in {cfg_path}.") + return 1, None + + print(f"[A] quantization_config from {cfg_path}:") + print(json.dumps(qc, indent=2)) + + quant_algo = qc.get("quant_algo") + issues = [] + if qc.get("quant_method") != "modelopt": + issues.append(f"quant_method={qc.get('quant_method')!r} (expected 'modelopt')") + if quant_algo not in SUPPORTED_ALGOS: + issues.append( + f"quant_algo={quant_algo!r} (expected one of {SUPPORTED_ALGOS} — vllm-omni adapter may not auto-detect)" + ) + + if issues: + print("[A] WARN — config looks incomplete:") + for issue in issues: + print(f" - {issue}") + return 2, quant_algo + print(f"[A] PASS — config looks correct (quant_algo={quant_algo}).") + return 0, quant_algo + + +def _read_safetensors_header(path: Path) -> dict: + """Read the JSON header of a safetensors file. Bypass-safe — doesn't materialize tensors. + + Returns {tensor_name: {'dtype': 'F8_E4M3', 'shape': [...], 'data_offsets': [...]}}. + Header dtype strings: F8_E4M3, F8_E5M2, BF16, F16, F32, F64, I8, I16, I32, I64, BOOL, U8, ... + """ + import struct + + with open(path, "rb") as f: + header_len = struct.unpack(" str: + """Infer per-tensor vs per-channel vs per-block from sample weight_scale shapes. + + ModelOpt block-wise produces shapes like `[16, 1, 16, 1]` (broadcasting dims of 1 + interleaved with block-count dims). We count "meaningful" dims — ones with size > 1 — + and classify: 0 meaningful dims = per-tensor (scalar), 1 = per-channel, 2+ = per-block. + """ + if not weight_scale_shapes: + return "no weight_scale tensors found" + + def meaningful_dims(shape: list[int]) -> int: + return sum(1 for d in shape if d > 1) + + per_tensor = sum(1 for s in weight_scale_shapes if meaningful_dims(s) == 0) + per_channel = sum(1 for s in weight_scale_shapes if meaningful_dims(s) == 1) + per_block = sum(1 for s in weight_scale_shapes if meaningful_dims(s) >= 2) + total = len(weight_scale_shapes) + if per_tensor == total: + return "per-tensor (all scalar scales)" + if per_channel == total: + return "per-channel (1 meaningful dim)" + if per_block == total: + return "per-block (2+ meaningful dims — e.g. [M//bm, 1, N//bn, 1] for tiles)" + return f"mixed: per-tensor={per_tensor}, per-channel={per_channel}, per-block={per_block} of {total}" + + +def _check_safetensors(transformer_dir: Path) -> int: + """Returns 0 on pass, 1 on fail. Reads on-disk dtype from the safetensors header.""" + files = sorted(transformer_dir.glob("*.safetensors")) + if not files: + print(f"[FAIL] No *.safetensors in {transformer_dir}.") + return 1 + + header_dtype_counts: Counter[str] = Counter() + sample_quant_weight_keys: list[str] = [] + sample_scale_keys: list[str] = [] + weight_scale_shapes: list[list[int]] = [] + sample_weight_scale_entries: list[tuple[str, list[int]]] = [] + + for f in files: + try: + header = _read_safetensors_header(f) + except Exception as exc: + print(f"[B] WARN — could not parse header of {f}: {exc}") + continue + for k, info in header.items(): + dtype = info.get("dtype", "?") + header_dtype_counts[dtype] += 1 + # FP8 stores weights as F8_E4M3 directly. + if dtype.startswith("F8") and k.endswith(".weight") and len(sample_quant_weight_keys) < 5: + sample_quant_weight_keys.append(k) + if k.endswith(("_scale", ".weight_scale", ".input_scale", "_scale_inv")) and len(sample_scale_keys) < 5: + sample_scale_keys.append(k) + if k.endswith(".weight_scale"): + weight_scale_shapes.append(info.get("shape", [])) + if len(sample_weight_scale_entries) < 5: + sample_weight_scale_entries.append((k, info.get("shape", []))) + + print(f"\n[B] On-disk dtype counts across {len(files)} safetensors file(s) (from header, not get_tensor):") + for dtype, count in sorted(header_dtype_counts.items(), key=lambda kv: -kv[1]): + marker = " <-- FP8" if dtype.startswith("F8") else "" + print(f" {dtype:10s} {count:>6d}{marker}") + + quant_count = sum(c for d, c in header_dtype_counts.items() if d.startswith("F8")) + if quant_count == 0: + print("[B] FAIL — no FP8 tensors on disk. Calibration likely did not actually quantize the weights.") + return 1 + + print(f"[B] PASS — {quant_count} FP8 tensors stored on disk.") + if sample_quant_weight_keys: + print(f" sample quantized weight tensors: {sample_quant_weight_keys[:3]}") + if sample_scale_keys: + print(f" sample scale tensors: {sample_scale_keys[:3]}") + print(" (Note: torch's get_tensor() may return these as bf16 views on some versions —") + print(" irrelevant; vLLM's loader uses native FP8 ops.)") + + # Weight-scale granularity — per-tensor (scalar) vs per-channel (1-D) vs per-block (N-D). + print(f"\n weight_scale granularity: {_classify_weight_scale_granularity(weight_scale_shapes)}") + for key, shape in sample_weight_scale_entries[:3]: + print(f" {key}: shape {shape}") + return 0 + + +def _disk_size_gib(p: Path) -> float: + return sum(f.stat().st_size for f in p.rglob("*") if f.is_file()) / (1024**3) + + +def _check_size_vs_baseline(transformer_dir: Path, baseline: str | None) -> int: + """Returns 0 always (informational only).""" + quant_size = _disk_size_gib(transformer_dir) + print(f"\n[C] FP8 transformer disk size: {quant_size:.2f} GiB") + + if baseline is None: + print("[C] SKIP — pass --baseline to compare against BF16.") + return 0 + + baseline_path = Path(baseline) + if not baseline_path.exists(): + # Try HF download. + try: + from huggingface_hub import snapshot_download + except ImportError: + print("[C] SKIP — huggingface_hub not installed and baseline not a local path.") + return 0 + print(f" Downloading baseline transformer from HF: {baseline}") + baseline_path = Path(snapshot_download(baseline, allow_patterns=["transformer/*"])) + + bf16_dir = baseline_path / "transformer" if (baseline_path / "transformer").exists() else baseline_path + bf16_size = _disk_size_gib(bf16_dir) + if bf16_size == 0: + print(f"[C] WARN — baseline transformer dir empty: {bf16_dir}") + return 0 + + # Expected reduction for FP8: ~50%. + min_reduction = 30 + reduction = (1 - quant_size / bf16_size) * 100 + print(f"[C] BF16 baseline transformer disk size: {bf16_size:.2f} GiB ({bf16_dir})") + print(f"[C] Disk reduction: {reduction:.1f}% (FP8 transformer is {quant_size / bf16_size:.0%} of BF16)") + if reduction < min_reduction: + print( + f"[C] WARN — FP8 should typically reduce disk by ~40-50%; <{min_reduction}% suggests partial quantization." + ) + return 0 + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--output", required=True, help="Path to the exported ModelOpt FP8 checkpoint root.") + p.add_argument( + "--baseline", + default=None, + help="Optional BF16 baseline (local diffusers dir or HF id) for disk-size comparison.", + ) + args = p.parse_args() + + out_root = Path(args.output).expanduser().resolve() + transformer_dir = out_root / "transformer" + if not transformer_dir.exists(): + print(f"[FAIL] {transformer_dir} does not exist.") + sys.exit(1) + + print(f"Checking: {out_root}\n") + + fail = 0 + config_status, _quant_algo = _check_config(transformer_dir) + fail |= config_status + fail |= _check_safetensors(transformer_dir) + _check_size_vs_baseline(transformer_dir, args.baseline) + + print() + if fail == 0: + print("=" * 60) + print("ALL CHECKS PASSED — checkpoint looks ready for vllm-omni serving.") + elif fail == 1: + print("=" * 60) + print("FAILURES detected — calibration may need to be re-run.") + sys.exit(1) + else: + print("=" * 60) + print("WARNINGS only — checkpoint may serve but with caveats. See [A] above.") + + +if __name__ == "__main__": + main() diff --git a/examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py b/examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py new file mode 100644 index 000000000..e6e3dd5c5 --- /dev/null +++ b/examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Quantize HunyuanVideo-1.5 (480p T2V) to a ModelOpt FP8 Hugging Face checkpoint. + +Calibrates the DiT transformer using a small video prompt set and exports a +diffusers-style directory whose transformer carries ModelOpt FP8 metadata. +The exported checkpoint is consumable by vllm-omni's ModelOpt FP8 adapter +(see vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py). + +Layers kept full precision match the #2728 / #2795 pattern: modulation, +AdaLayerNorm, entry/exit projections, embeddings, the token refiner path, +and final proj_out. MHA quantizers are off by default; HV-1.5 self-attention +empirically degrades under FP8 (see #2920 ablation). + +Example: + python examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py \\ + --model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v \\ + --output ./hv15-480p-modelopt-fp8 \\ + --overwrite +""" + +from __future__ import annotations + +import argparse +import copy +import json +import re +import shutil +import sys +from pathlib import Path +from typing import Any + +import torch +from diffusers import DiffusionPipeline + +DEFAULT_PROMPTS = [ + "A dog running across a field of golden wheat.", + "An astronaut riding a horse across the surface of Mars, red dust swirling, cinematic wide shot.", + "A hummingbird hovering in front of a vibrant red flower, slow motion, macro shot.", + "A crackling campfire at night under a starry sky, sparks rising into the dark.", + "An underwater shot of a coral reef with tropical fish swimming by, sun rays piercing the water.", + "A close-up of a blooming rose covered in morning dew, soft natural light.", + "A peaceful mountain village at dawn, mist rolling over the rooftops, cinematic establishing shot.", + "A skateboarder doing a kickflip in an urban plaza, slow motion, golden hour lighting.", +] + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--model", required=True, help="Input HV-1.5 diffusers directory or HF id.") + p.add_argument("--output", required=True, help="Output directory for the ModelOpt FP8 checkpoint.") + p.add_argument("--dtype", choices=("bfloat16", "float16"), default="bfloat16") + p.add_argument("--height", type=int, default=480) + p.add_argument("--width", type=int, default=832) + p.add_argument( + "--num-frames", + type=int, + default=33, + help="Frames per calibration sample. 33 matches the typical short benchmark.", + ) + p.add_argument("--guidance-scale", type=float, default=6.0) + p.add_argument( + "--calib-steps", + type=int, + default=10, + help="Denoising steps per calibration prompt (10 is enough for amax statistics).", + ) + p.add_argument("--calib-size", type=int, default=8, help="How many prompts to use for calibration.") + p.add_argument("--seed", type=int, default=42) + p.add_argument( + "--prompt", + action="append", + default=[], + help="Custom calibration prompt. Repeat to provide multiple.", + ) + p.add_argument( + "--quantize-mha", + action="store_true", + help="Enable FP8 attention K/V/softmax quantizers. Off by default — empirically degrades HV-1.5 video output.", + ) + p.add_argument( + "--weight-block-size", + type=str, + default=None, + help="Per-block weight quantization as 'M,N' (e.g. '128,128' for 128x128 tiles). " + "Default: per-tensor (one scale per linear). Block-wise typically gives tighter quality at " + "negligible memory cost. Static FP8 is exempt from upstream vLLM's online block-wise gate.", + ) + p.add_argument("--overwrite", action="store_true", help="Replace an existing output directory.") + return p + + +def _parse_block_size(spec: str | None) -> list[int] | None: + if spec is None: + return None + parts = [int(x) for x in spec.split(",") if x.strip()] + if len(parts) != 2: + raise SystemExit(f"--weight-block-size must be 'M,N' (2 ints), got {spec!r}") + return parts + + +def _require_modelopt() -> Any: + try: + import modelopt.torch.quantization as mtq + except ModuleNotFoundError as exc: + raise SystemExit( + "NVIDIA ModelOpt is not installed. Install with:\n" + " pip install 'nvidia-modelopt[all]'\n" + f"Original error: {exc}" + ) from exc + return mtq + + +def _ensure_paths(args: argparse.Namespace) -> tuple[str, Path]: + model_path = args.model + output_dir = Path(args.output).expanduser().resolve() + if output_dir.exists(): + if not args.overwrite: + raise SystemExit(f"Output directory already exists: {output_dir}\nPass --overwrite to replace it.") + shutil.rmtree(output_dir) + return model_path, output_dir + + +def _select_dtype(name: str) -> torch.dtype: + return {"bfloat16": torch.bfloat16, "float16": torch.float16}[name] + + +def _build_prompts(args: argparse.Namespace) -> list[str]: + prompts = args.prompt or DEFAULT_PROMPTS + if args.calib_size <= 0: + raise SystemExit("--calib-size must be positive.") + if len(prompts) < args.calib_size: + repeats = (args.calib_size + len(prompts) - 1) // len(prompts) + prompts = (prompts * repeats)[: args.calib_size] + return prompts[: args.calib_size] + + +# Layers to KEEP at full precision (mirror of the #2920 wiring + #2728/#2795 skip pattern). +# - x_embedder, image_embedder, context_embedder*, time_embed*, cond_type_embed: entry/embedding +# - norm_out, norm1*.linear, norm1_context*.linear, norm2*, norm2_context*: AdaLayerNorm modulation +# - proj_out: final output projection +# - token_refiner*: text-encoder refinement uses diffusers raw nn.Linear +def _filter_func_hv15(name: str) -> bool: + pattern = re.compile( + r"(proj_out.*|" + r".*(x_embedder|image_embedder|context_embedder|context_embedder_2|" + r"time_embed|cond_type_embed|" + r"norm_out|norm1\.linear|norm1_context\.linear|norm2|norm2_context|" + r"token_refiner).*)" + ) + return pattern.match(name) is not None + + +def _mha_filter_func(name: str) -> bool: + pattern = re.compile( + r".*(q_bmm_quantizer|k_bmm_quantizer|v_bmm_quantizer|softmax_quantizer|bmm2_output_quantizer).*" + ) + return pattern.match(name) is not None + + +def _disable_known_problematic_quantizers(mtq: Any, backbone: torch.nn.Module, *, quantize_mha: bool) -> None: + if not hasattr(mtq, "disable_quantizer"): + return + mtq.disable_quantizer(backbone, _filter_func_hv15) + if not quantize_mha: + mtq.disable_quantizer(backbone, _mha_filter_func) + + +def _load_pipeline(model_path: str, dtype: torch.dtype) -> DiffusionPipeline: + pipe = DiffusionPipeline.from_pretrained(model_path, torch_dtype=dtype) + if hasattr(pipe, "set_progress_bar_config"): + pipe.set_progress_bar_config(disable=True) + pipe.to("cuda") + return pipe + + +def _build_forward_loop(pipe: DiffusionPipeline, args: argparse.Namespace, prompts: list[str]): + generator = torch.Generator(device="cuda") + + # Try to set guidance on the pipeline's guider object up front (modern + # diffusers HV-1.5 uses a Guider abstraction, not a per-call kwarg). Falls + # back silently — calibration uses whatever default the pipeline ships with. + guider = getattr(pipe, "guider", None) + if guider is not None and hasattr(guider, "guidance_scale"): + try: + guider.guidance_scale = args.guidance_scale + except Exception: + pass + + base_kwargs = dict( + height=args.height, + width=args.width, + num_frames=args.num_frames, + num_inference_steps=args.calib_steps, + output_type="latent", + ) + + def forward_loop(*_unused_args, **_unused_kwargs) -> None: + with torch.inference_mode(): + for idx, prompt in enumerate(prompts): + generator.manual_seed(args.seed + idx) + # Try with guidance_scale first; fall back without on TypeError + # for pipelines (like HV-1.5) that take CFG via guider config. + try: + pipe(prompt=prompt, generator=generator, guidance_scale=args.guidance_scale, **base_kwargs) + except TypeError as exc: + if "guidance_scale" not in str(exc): + raise + pipe(prompt=prompt, generator=generator, **base_kwargs) + + return forward_loop + + +def _summarize_export(output_dir: Path) -> None: + cfg_path = output_dir / "transformer" / "config.json" + if not cfg_path.exists(): + print(f"[warn] {cfg_path} missing.", file=sys.stderr) + return + with cfg_path.open(encoding="utf-8") as f: + cfg = json.load(f) + qc = cfg.get("quantization_config") + if not isinstance(qc, dict): + print("[warn] No quantization_config in transformer/config.json.", file=sys.stderr) + return + print("Export summary:") + print(f" quant_method: {qc.get('quant_method')}") + print(f" quant_algo: {qc.get('quant_algo')}") + producer = qc.get("producer") + if isinstance(producer, dict): + print(f" producer: {producer.get('name')} {producer.get('version')}") + print(f" config path: {cfg_path}") + + +def _force_export_quantized_weights(backbone: torch.nn.Module, dtype: torch.dtype) -> int: + """Convert in-memory weights of quantized modules to actual FP8 storage. + + `export_hf_checkpoint` skips this step for unknown model types (HV-1.5 isn't + in ModelOpt's recognized-model registry), so we must call the per-weight + export helper ourselves. Same workaround as the HunyuanImage-3 calibration + helper. + """ + from modelopt.torch.export.quant_utils import ( + QUANTIZATION_NONE, + get_quantization_format, + quantizer_attr_names, + weight_attr_names, + ) + from modelopt.torch.export.unified_export_hf import _export_quantized_weight + + exported = 0 + for name, module in backbone.named_modules(): + try: + quantization_format = get_quantization_format(module) + except Exception as exc: + print(f"[warn] Could not inspect quantization format for {name}: {exc}", file=sys.stderr) + continue + if quantization_format == QUANTIZATION_NONE: + continue + for weight_name in weight_attr_names(module): + quantizer_attrs = quantizer_attr_names(weight_name) + weight_quantizer = getattr(module, quantizer_attrs.weight_quantizer, None) + if weight_quantizer is None or not getattr(weight_quantizer, "is_enabled", False): + continue + _export_quantized_weight(module, dtype, weight_name) + exported += 1 + return exported + + +def _hv15_quant_config_block(weight_block_size: list[int] | None = None) -> dict: + """Mirror ModelOpt FP8 metadata expected by vllm-omni's adapter (#2913). + + Same shape as the HunyuanImage-3 author's _hunyuan_quant_config(). When + `weight_block_size` is given, advertise block-wise weight quantization in + the saved metadata (so consumers know to expect multi-element scale tensors). + """ + weights_cfg: dict = {"dynamic": False, "num_bits": 8, "type": "float"} + if weight_block_size is not None: + weights_cfg["strategy"] = "block" + weights_cfg["block_structure"] = f"{weight_block_size[0]}x{weight_block_size[1]}" + return { + "config_groups": { + "group_0": { + "input_activations": {"dynamic": False, "num_bits": 8, "type": "float"}, + "weights": weights_cfg, + "targets": ["Linear"], + } + }, + "ignore": [ + "context_embedder*", + "context_embedder_2*", + "cond_type_embed*", + "image_embedder*", + "norm1.linear*", + "norm1_context.linear*", + "norm2*", + "norm2_context*", + "norm_out*", + "proj_out*", + "time_embed*", + "token_refiner*", + "x_embedder*", + ], + "producer": {"name": "modelopt"}, + "quant_algo": "FP8", + "quant_method": "modelopt", + } + + +def _patch_quant_config(output_dir: Path, weight_block_size: list[int] | None = None) -> None: + """Inject quant_algo: FP8 + config_groups into transformer/config.json so + vllm-omni's adapter (#2913) recognises the checkpoint as ModelOpt FP8.""" + cfg_path = output_dir / "transformer" / "config.json" + with cfg_path.open(encoding="utf-8") as f: + cfg = json.load(f) + + new_qc = _hv15_quant_config_block(weight_block_size=weight_block_size) + existing = cfg.get("quantization_config") + if isinstance(existing, dict): + producer = existing.get("producer") + if isinstance(producer, dict): + new_qc["producer"] = producer + + cfg["quantization_config"] = new_qc + with cfg_path.open("w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2) + + +def _save_pipeline_with_fp8_transformer( + pipe: DiffusionPipeline, + model_path: str, + output_dir: Path, + max_shard_size: str = "5GB", +) -> None: + """Save the pipeline with the (now FP8) transformer. + + Copies the source directory verbatim except for `transformer/`, then + saves the transformer with quantizers hidden so the state dict contains + only the FP8 weights + scale tensors. + """ + from modelopt.torch.export.diffusers_utils import hide_quantizers_from_state_dict + + src = Path(model_path) + if not src.exists(): + from huggingface_hub import snapshot_download + + src = Path(snapshot_download(model_path)) + + if output_dir.exists(): + shutil.rmtree(output_dir) + shutil.copytree(src, output_dir, ignore=shutil.ignore_patterns("transformer")) + + transformer_out = output_dir / "transformer" + # `hide_quantizers_from_state_dict` walks named_modules(); pass the actual + # nn.Module (transformer), not the diffusers Pipeline wrapper. + with hide_quantizers_from_state_dict(pipe.transformer): + pipe.transformer.save_pretrained( + str(transformer_out), + safe_serialization=True, + max_shard_size=max_shard_size, + ) + + +def main() -> None: + args = _build_parser().parse_args() + if not torch.cuda.is_available(): + raise SystemExit("CUDA is required for ModelOpt FP8 quantization.") + + mtq = _require_modelopt() + model_path, output_dir = _ensure_paths(args) + dtype = _select_dtype(args.dtype) + prompts = _build_prompts(args) + + print("Quantization plan:") + weight_block_size = _parse_block_size(args.weight_block_size) + + print(f" input: {args.model}") + print(f" output: {output_dir}") + print(f" dtype: {dtype}") + print(f" height/width: {args.height}x{args.width}") + print(f" num_frames: {args.num_frames}") + print(f" calib_size: {len(prompts)}") + print(f" calib_steps: {args.calib_steps}") + print(f" quantize_mha: {args.quantize_mha}") + print( + f" weight strategy: {'block-wise ' + str(weight_block_size) if weight_block_size else 'per-tensor (default)'}" + ) + + pipe = _load_pipeline(model_path, dtype) + backbone = pipe.transformer + + quant_config = copy.deepcopy(mtq.FP8_DEFAULT_CFG) + if weight_block_size is not None: + # Switch from per-tensor (default) to block-wise weight quantization. + # ModelOpt's wildcard "*weight_quantizer" matches every linear's weight quantizer. + quant_config["quant_cfg"]["*weight_quantizer"] = { + "num_bits": (4, 3), # E4M3 (FP8 weights, same as default) + "block_sizes": {-1: weight_block_size[1], -2: weight_block_size[0]}, + } + print( + f" -> overriding weight quantizer with block_sizes={weight_block_size} " + f"({weight_block_size[0]}x{weight_block_size[1]} tiles)" + ) + + forward_loop = _build_forward_loop(pipe, args, prompts) + quantized = mtq.quantize(backbone, quant_config, forward_loop) + if quantized is not None: + pipe.transformer = quantized + backbone = quantized + + _disable_known_problematic_quantizers(mtq, backbone, quantize_mha=args.quantize_mha) + + print("\nForcing FP8 weight serialization (HV-1.5 isn't in ModelOpt's recognized-model registry,") + print("so we have to call the per-weight export helper ourselves)...") + exported = _force_export_quantized_weights(backbone, dtype) + print(f" -> {exported} weights converted to FP8 in memory") + if exported == 0: + raise SystemExit( + "No quantized weights were exported. Calibration may have skipped every layer " + "(check the disable_quantizer regex) or `mtq.quantize` did not actually wrap any " + "weight quantizers." + ) + + print("\nSaving pipeline with FP8 transformer...") + _save_pipeline_with_fp8_transformer(pipe, model_path, output_dir) + _patch_quant_config(output_dir, weight_block_size=weight_block_size) + print(f"Saved to: {output_dir}") + _summarize_export(output_dir) + + print("\nNext: validate the checkpoint with vllm-omni:") + print( + " python examples/offline_inference/text_to_video/text_to_video.py \\\n" + f" --model {output_dir} \\\n" + " --quantization fp8 \\\n" + " --prompt 'A dog running across a field of golden wheat.' \\\n" + f" --height {args.height} --width {args.width} --num-frames {args.num_frames} \\\n" + " --num-inference-steps 30 --guidance-scale 6.0 --seed 42 \\\n" + " --output outputs/hv15_modelopt_fp8.mp4 \\\n" + " --enforce-eager" + ) + print( + "\n (--quantization fp8 is auto-upgraded to ModelOpt FP8 at runtime because the " + "checkpoint's config.json has modelopt metadata.)" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/quantization/quantize_wan2_2_modelopt_fp8.py b/examples/quantization/quantize_wan2_2_modelopt_fp8.py new file mode 100644 index 000000000..6b6cc42a4 --- /dev/null +++ b/examples/quantization/quantize_wan2_2_modelopt_fp8.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Quantize Wan2.2 (TI2V-5B, 704x1280 T2V) to a ModelOpt FP8 Hugging Face checkpoint. + +Calibrates the DiT transformer using a small video prompt set and exports a +diffusers-style directory whose transformer carries ModelOpt FP8 metadata. +The exported checkpoint is consumable by vllm-omni's ModelOpt FP8 adapter +(see vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py). + +Layers kept full precision match the #2728 / #2795 pattern: condition embedder +(time/text/image), patch embedding, modulation (scale_shift_table), final +norm + proj_out, and sequence-parallel helpers. All attention + FFN linears +are quantized — static calibration handles the numerics that online FP8 +couldn't (see #2920 ablation). + +Default target is `Wan-AI/Wan2.2-TI2V-5B-Diffusers`, the dense 5B variant that +fits 80GB BF16. The A14B MoE variants need 2+ GPUs and are out of scope here. + +Example: + python examples/quantization/quantize_wan2_2_modelopt_fp8.py \\ + --model Wan-AI/Wan2.2-TI2V-5B-Diffusers \\ + --output ./wan22-ti2v-modelopt-fp8 \\ + --overwrite +""" + +from __future__ import annotations + +import argparse +import copy +import json +import re +import shutil +import sys +from pathlib import Path +from typing import Any + +import torch +from diffusers import DiffusionPipeline + +DEFAULT_PROMPTS = [ + "A dog running across a field of golden wheat.", + "An astronaut riding a horse across the surface of Mars, red dust swirling, cinematic wide shot.", + "A hummingbird hovering in front of a vibrant red flower, slow motion, macro shot.", + "A crackling campfire at night under a starry sky, sparks rising into the dark.", + "An underwater shot of a coral reef with tropical fish swimming by, sun rays piercing the water.", + "A close-up of a blooming rose covered in morning dew, soft natural light.", + "A peaceful mountain village at dawn, mist rolling over the rooftops, cinematic establishing shot.", + "A skateboarder doing a kickflip in an urban plaza, slow motion, golden hour lighting.", +] + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--model", required=True, help="Input Wan2.2 diffusers directory or HF id.") + p.add_argument("--output", required=True, help="Output directory for the ModelOpt FP8 checkpoint.") + p.add_argument("--dtype", choices=("bfloat16", "float16"), default="bfloat16") + p.add_argument("--height", type=int, default=704, help="Calibration video height (Wan2.2 TI2V-5B native: 704).") + p.add_argument("--width", type=int, default=1280, help="Calibration video width (Wan2.2 TI2V-5B native: 1280).") + p.add_argument( + "--num-frames", + type=int, + default=49, + help="Frames per calibration sample. 49 matches the typical short benchmark; " + "use 17 to reduce memory pressure during calibration.", + ) + p.add_argument("--guidance-scale", type=float, default=5.0) + p.add_argument( + "--calib-steps", + type=int, + default=10, + help="Denoising steps per calibration prompt (10 is enough for amax statistics).", + ) + p.add_argument("--calib-size", type=int, default=8, help="How many prompts to use for calibration.") + p.add_argument("--seed", type=int, default=42) + p.add_argument( + "--prompt", + action="append", + default=[], + help="Custom calibration prompt. Repeat to provide multiple.", + ) + p.add_argument( + "--quantize-mha", + action="store_true", + help="Enable FP8 attention K/V/softmax quantizers. Off by default — Wan2.2's long attention " + "sequences amplified FP8 drift in the online ablation (see #2920).", + ) + p.add_argument( + "--weight-block-size", + type=str, + default=None, + help="Per-block weight quantization as 'M,N' (e.g. '128,128'). Default per-tensor. " + "Note: vllm-omni's ModelOpt adapter may not yet dispatch block-wise scales — check #2924 " + "for the HV-1.5 investigation status before relying on this.", + ) + p.add_argument("--overwrite", action="store_true", help="Replace an existing output directory.") + return p + + +def _parse_block_size(spec: str | None) -> list[int] | None: + if spec is None: + return None + parts = [int(x) for x in spec.split(",") if x.strip()] + if len(parts) != 2: + raise SystemExit(f"--weight-block-size must be 'M,N' (2 ints), got {spec!r}") + return parts + + +def _require_modelopt() -> Any: + try: + import modelopt.torch.quantization as mtq + except ModuleNotFoundError as exc: + raise SystemExit( + "NVIDIA ModelOpt is not installed. Install with:\n" + " pip install 'nvidia-modelopt[all]'\n" + f"Original error: {exc}" + ) from exc + return mtq + + +def _ensure_paths(args: argparse.Namespace) -> tuple[str, Path]: + model_path = args.model + output_dir = Path(args.output).expanduser().resolve() + if output_dir.exists(): + if not args.overwrite: + raise SystemExit(f"Output directory already exists: {output_dir}\nPass --overwrite to replace it.") + shutil.rmtree(output_dir) + return model_path, output_dir + + +def _select_dtype(name: str) -> torch.dtype: + return {"bfloat16": torch.bfloat16, "float16": torch.float16}[name] + + +def _build_prompts(args: argparse.Namespace) -> list[str]: + prompts = args.prompt or DEFAULT_PROMPTS + if args.calib_size <= 0: + raise SystemExit("--calib-size must be positive.") + if len(prompts) < args.calib_size: + repeats = (args.calib_size + len(prompts) - 1) // len(prompts) + prompts = (prompts * repeats)[: args.calib_size] + return prompts[: args.calib_size] + + +# Layers to KEEP at full precision. Wan2.2's module naming: +# - condition_embedder: time_embedder, time_proj, text_embedder, image_embedder (I2V) +# - patch_embedding: Conv3dLayer (already not Linear, belt-and-suspenders skip) +# - scale_shift_table: nn.Parameter modulation (not Linear, but pattern guard) +# - norm_out: AdaLayerNorm final +# - proj_out: final nn.Linear +# - timestep_proj_prepare / output_scale_shift_prepare: SP helpers +def _filter_func_wan22(name: str) -> bool: + pattern = re.compile( + r"(proj_out.*|" + r".*(condition_embedder|patch_embedding|" + r"norm_out|scale_shift_table|" + r"timestep_proj_prepare|output_scale_shift_prepare).*)" + ) + return pattern.match(name) is not None + + +def _mha_filter_func(name: str) -> bool: + pattern = re.compile( + r".*(q_bmm_quantizer|k_bmm_quantizer|v_bmm_quantizer|softmax_quantizer|bmm2_output_quantizer).*" + ) + return pattern.match(name) is not None + + +def _disable_known_problematic_quantizers(mtq: Any, backbone: torch.nn.Module, *, quantize_mha: bool) -> None: + if not hasattr(mtq, "disable_quantizer"): + return + mtq.disable_quantizer(backbone, _filter_func_wan22) + if not quantize_mha: + mtq.disable_quantizer(backbone, _mha_filter_func) + + +def _load_pipeline(model_path: str, dtype: torch.dtype) -> DiffusionPipeline: + pipe = DiffusionPipeline.from_pretrained(model_path, torch_dtype=dtype) + if hasattr(pipe, "set_progress_bar_config"): + pipe.set_progress_bar_config(disable=True) + pipe.to("cuda") + return pipe + + +def _build_forward_loop(pipe: DiffusionPipeline, args: argparse.Namespace, prompts: list[str]): + generator = torch.Generator(device="cuda") + + # Try setting guidance on the pipeline's guider if present (newer diffusers APIs). + guider = getattr(pipe, "guider", None) + if guider is not None and hasattr(guider, "guidance_scale"): + try: + guider.guidance_scale = args.guidance_scale + except Exception: + pass + + base_kwargs = dict( + height=args.height, + width=args.width, + num_frames=args.num_frames, + num_inference_steps=args.calib_steps, + output_type="latent", + ) + + def forward_loop(*_unused_args, **_unused_kwargs) -> None: + with torch.inference_mode(): + for idx, prompt in enumerate(prompts): + generator.manual_seed(args.seed + idx) + # Try with guidance_scale first; fall back without on TypeError + # for pipelines that take CFG via guider config only. + try: + pipe(prompt=prompt, generator=generator, guidance_scale=args.guidance_scale, **base_kwargs) + except TypeError as exc: + if "guidance_scale" not in str(exc): + raise + pipe(prompt=prompt, generator=generator, **base_kwargs) + + return forward_loop + + +def _summarize_export(output_dir: Path) -> None: + cfg_path = output_dir / "transformer" / "config.json" + if not cfg_path.exists(): + print(f"[warn] {cfg_path} missing.", file=sys.stderr) + return + with cfg_path.open(encoding="utf-8") as f: + cfg = json.load(f) + qc = cfg.get("quantization_config") + if not isinstance(qc, dict): + print("[warn] No quantization_config in transformer/config.json.", file=sys.stderr) + return + print("Export summary:") + print(f" quant_method: {qc.get('quant_method')}") + print(f" quant_algo: {qc.get('quant_algo')}") + producer = qc.get("producer") + if isinstance(producer, dict): + print(f" producer: {producer.get('name')} {producer.get('version')}") + print(f" config path: {cfg_path}") + + +def _force_export_quantized_weights(backbone: torch.nn.Module, dtype: torch.dtype) -> int: + """Convert in-memory weights of quantized modules to actual FP8 storage. + + `export_hf_checkpoint` skips this step for unknown model types (Wan2.2 isn't + in ModelOpt's recognized-model registry), so we must call the per-weight + export helper ourselves. Same workaround as the HunyuanVideo-1.5 / HunyuanImage-3 + calibration helpers. + """ + from modelopt.torch.export.quant_utils import ( + QUANTIZATION_NONE, + get_quantization_format, + quantizer_attr_names, + weight_attr_names, + ) + from modelopt.torch.export.unified_export_hf import _export_quantized_weight + + exported = 0 + for name, module in backbone.named_modules(): + try: + quantization_format = get_quantization_format(module) + except Exception as exc: + print(f"[warn] Could not inspect quantization format for {name}: {exc}", file=sys.stderr) + continue + if quantization_format == QUANTIZATION_NONE: + continue + for weight_name in weight_attr_names(module): + quantizer_attrs = quantizer_attr_names(weight_name) + weight_quantizer = getattr(module, quantizer_attrs.weight_quantizer, None) + if weight_quantizer is None or not getattr(weight_quantizer, "is_enabled", False): + continue + _export_quantized_weight(module, dtype, weight_name) + exported += 1 + return exported + + +def _wan22_quant_config_block(weight_block_size: list[int] | None = None) -> dict: + """Mirror ModelOpt FP8 metadata expected by vllm-omni's adapter (#2913).""" + weights_cfg: dict = {"dynamic": False, "num_bits": 8, "type": "float"} + if weight_block_size is not None: + weights_cfg["strategy"] = "block" + weights_cfg["block_structure"] = f"{weight_block_size[0]}x{weight_block_size[1]}" + return { + "config_groups": { + "group_0": { + "input_activations": {"dynamic": False, "num_bits": 8, "type": "float"}, + "weights": weights_cfg, + "targets": ["Linear"], + } + }, + "ignore": [ + "condition_embedder*", + "norm_out*", + "output_scale_shift_prepare*", + "patch_embedding*", + "proj_out*", + "scale_shift_table*", + "timestep_proj_prepare*", + ], + "producer": {"name": "modelopt"}, + "quant_algo": "FP8", + "quant_method": "modelopt", + } + + +def _patch_quant_config(output_dir: Path, weight_block_size: list[int] | None = None) -> None: + """Inject quant_algo: FP8 + config_groups into each transformer's config.json + so vllm-omni's adapter (#2913) recognises the checkpoint as ModelOpt FP8. + + Patches both ``transformer`` and ``transformer_2`` (A14B) when present.""" + for sub in ("transformer", "transformer_2"): + cfg_path = output_dir / sub / "config.json" + if not cfg_path.exists(): + continue + with cfg_path.open(encoding="utf-8") as f: + cfg = json.load(f) + + new_qc = _wan22_quant_config_block(weight_block_size=weight_block_size) + existing = cfg.get("quantization_config") + if isinstance(existing, dict): + producer = existing.get("producer") + if isinstance(producer, dict): + new_qc["producer"] = producer + + cfg["quantization_config"] = new_qc + with cfg_path.open("w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2) + + +def _list_transformers(pipe: DiffusionPipeline) -> list[tuple[str, torch.nn.Module]]: + """Return [(attr_name, module)] for every DiT on the pipeline. + + Wan2.2 A14B has both ``transformer`` (low-noise) and ``transformer_2`` + (high-noise). TI2V-5B has only ``transformer``. + """ + out = [] + for attr in ("transformer", "transformer_2"): + mod = getattr(pipe, attr, None) + if mod is not None: + out.append((attr, mod)) + return out + + +def _save_pipeline_with_fp8_transformer( + pipe: DiffusionPipeline, + model_path: str, + output_dir: Path, + max_shard_size: str = "5GB", +) -> None: + """Copy source dir verbatim minus transformer dirs, then save each quantized DiT. + + Handles A14B's dual transformer (``transformer`` + ``transformer_2``) as well + as the single-DiT TI2V-5B. + """ + from modelopt.torch.export.diffusers_utils import hide_quantizers_from_state_dict + + src = Path(model_path) + if not src.exists(): + from huggingface_hub import snapshot_download + + src = Path(snapshot_download(model_path)) + + if output_dir.exists(): + shutil.rmtree(output_dir) + shutil.copytree(src, output_dir, ignore=shutil.ignore_patterns("transformer", "transformer_2")) + + for attr, backbone in _list_transformers(pipe): + transformer_out = output_dir / attr + # Pass the nn.Module (transformer), not the Pipeline wrapper. + with hide_quantizers_from_state_dict(backbone): + backbone.save_pretrained( + str(transformer_out), + safe_serialization=True, + max_shard_size=max_shard_size, + ) + + +def main() -> None: + args = _build_parser().parse_args() + if not torch.cuda.is_available(): + raise SystemExit("CUDA is required for ModelOpt FP8 quantization.") + + mtq = _require_modelopt() + model_path, output_dir = _ensure_paths(args) + dtype = _select_dtype(args.dtype) + prompts = _build_prompts(args) + weight_block_size = _parse_block_size(args.weight_block_size) + + print("Quantization plan:") + print(f" input: {args.model}") + print(f" output: {output_dir}") + print(f" dtype: {dtype}") + print(f" height/width: {args.height}x{args.width}") + print(f" num_frames: {args.num_frames}") + print(f" calib_size: {len(prompts)}") + print(f" calib_steps: {args.calib_steps}") + print(f" quantize_mha: {args.quantize_mha}") + print( + f" weight strategy: {'block-wise ' + str(weight_block_size) if weight_block_size else 'per-tensor (default)'}" + ) + + pipe = _load_pipeline(model_path, dtype) + transformers = _list_transformers(pipe) + if not transformers: + raise SystemExit("Pipeline has no transformer or transformer_2 attribute.") + print(f" found {len(transformers)} transformer(s): {', '.join(a for a, _ in transformers)}") + + quant_config = copy.deepcopy(mtq.FP8_DEFAULT_CFG) + if weight_block_size is not None: + quant_config["quant_cfg"]["*weight_quantizer"] = { + "num_bits": (4, 3), + "block_sizes": {-1: weight_block_size[1], -2: weight_block_size[0]}, + } + print( + f" -> overriding weight quantizer with block_sizes={weight_block_size} " + f"({weight_block_size[0]}x{weight_block_size[1]} tiles)" + ) + + forward_loop = _build_forward_loop(pipe, args, prompts) + + # Quantize each DiT. The forward_loop runs the full pipeline, so quantizers + # on every attached transformer (A14B: transformer + transformer_2) get + # calibrated during the same passes. + for attr, backbone in transformers: + print(f"\nQuantizing {attr}...") + quantized = mtq.quantize(backbone, quant_config, forward_loop) + if quantized is not None: + setattr(pipe, attr, quantized) + _disable_known_problematic_quantizers(mtq, getattr(pipe, attr), quantize_mha=args.quantize_mha) + + # Export weights to FP8 only AFTER all transformers are calibrated. Exporting + # inside the loop would convert transformer's weights to real FP8, and the + # next transformer's forward_loop (full pipeline) would then fake-quantize an + # already-FP8 weight -> "fake_e4m3fy not implemented for Float8_e4m3fn". + print("\nForcing FP8 weight serialization (Wan2.2 isn't in ModelOpt's recognized-model registry,") + print("so we have to call the per-weight export helper ourselves)...") + total_exported = 0 + for attr, backbone in _list_transformers(pipe): + exported = _force_export_quantized_weights(backbone, dtype) + print(f" -> {exported} weights converted to FP8 in {attr}") + total_exported += exported + + if total_exported == 0: + raise SystemExit( + "No quantized weights were exported. Calibration may have skipped every layer " + "(check the disable_quantizer regex) or `mtq.quantize` did not actually wrap any " + "weight quantizers." + ) + + print("\nSaving pipeline with FP8 transformer(s)...") + _save_pipeline_with_fp8_transformer(pipe, model_path, output_dir) + _patch_quant_config(output_dir, weight_block_size=weight_block_size) + print(f"Saved to: {output_dir}") + _summarize_export(output_dir) + + print("\nNext: validate the checkpoint with vllm-omni:") + print( + " python examples/offline_inference/text_to_video/text_to_video.py \\\n" + f" --model {output_dir} \\\n" + " --quantization fp8 \\\n" + " --prompt 'A dog running across a field of golden wheat.' \\\n" + f" --height {args.height} --width {args.width} --num-frames {args.num_frames} \\\n" + " --num-inference-steps 30 --guidance-scale 5.0 --seed 42 \\\n" + " --output outputs/wan22_modelopt_fp8.mp4" + ) + print( + "\n (--quantization fp8 is auto-upgraded to ModelOpt FP8 at runtime because the " + "checkpoint's config.json has modelopt metadata.)" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/accuracy/test_videogen_modelopt_quant.py b/tests/e2e/accuracy/test_videogen_modelopt_quant.py new file mode 100644 index 000000000..e91c15175 --- /dev/null +++ b/tests/e2e/accuracy/test_videogen_modelopt_quant.py @@ -0,0 +1,273 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""End-to-end ModelOpt FP8 quality accuracy for video-gen DiTs. + +Modeled on ``test_hunyuan_image3.py``'s ``test_quantized_dit_matches_bf16_accuracy``: +for each calibrated FP8 checkpoint the test generates one video with BF16 and one +with the FP8 DiT under the same seed, scores prompt-faithfulness with CLIP on the +middle frame, then asserts both ``CLIP >= absolute_floor`` and +``CLIP_drop <= drop_threshold``. The test is gated by an opt-in env var (model +paths are local) so it never runs in generic CI. + +To run:: + + VIDEOGEN_RUN_QUANT_ACCURACY=1 \\ + WAN22_A14B_BF16_MODEL=Wan-AI/Wan2.2-T2V-A14B-Diffusers \\ + WAN22_A14B_FP8_MODEL=/path/to/wan22-a14b-modelopt-fp8 \\ + HV15_BF16_MODEL=hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v \\ + HV15_FP8_MODEL=/path/to/hv15-modelopt-fp8 \\ + pytest -s -v tests/e2e/accuracy/test_videogen_modelopt_quant.py +""" + +from __future__ import annotations + +import gc +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +import torch +from PIL import Image + +from tests.e2e.accuracy.helpers import CLIPScorer, model_output_dir + +pytestmark = [pytest.mark.local_model, pytest.mark.diffusion] + +# --------------------------------------------------------------------------- +# Tunables (override via env to suit local hardware / calibration budget) +# --------------------------------------------------------------------------- +SEED = 42 +HEIGHT = int(os.environ.get("VIDEOGEN_QUANT_HEIGHT", "480")) +WIDTH = int(os.environ.get("VIDEOGEN_QUANT_WIDTH", "832")) +NUM_FRAMES = int(os.environ.get("VIDEOGEN_QUANT_NUM_FRAMES", "17")) +NUM_INFERENCE_STEPS = int(os.environ.get("VIDEOGEN_QUANT_STEPS", "20")) + +WAN_PROMPT = "A red fox running through a snowy forest at sunrise, cinematic." +HV_PROMPT = "A red fox running through a snowy forest at sunrise, cinematic." + +# CLIP gates: absolute floor + max drop from the BF16 reference. +CLIP_ABSOLUTE_FLOOR = float(os.environ.get("VIDEOGEN_QUANT_CLIP_FLOOR", "20.0")) +CLIP_DROP_FP8 = float(os.environ.get("VIDEOGEN_QUANT_CLIP_DROP_FP8", "7.0")) + +RUN_ENV = "VIDEOGEN_RUN_QUANT_ACCURACY" +_TRUE = {"1", "true", "yes", "on"} + + +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class _Spec: + """A model under test (BF16 + its FP8 variant).""" + + family: str + bf16_env: str + fp8_env: str + prompt: str + guidance_scale: float + guidance_scale_2: float | None = None + boundary_ratio: float | None = None + + +WAN_SPEC = _Spec( + family="wan22_a14b_t2v", + bf16_env="WAN22_A14B_BF16_MODEL", + fp8_env="WAN22_A14B_FP8_MODEL", + prompt=WAN_PROMPT, + guidance_scale=3.5, + guidance_scale_2=4.0, + boundary_ratio=0.875, +) + +HV_SPEC = _Spec( + family="hunyuanvideo15_t2v", + bf16_env="HV15_BF16_MODEL", + fp8_env="HV15_FP8_MODEL", + prompt=HV_PROMPT, + guidance_scale=6.0, +) + + +@dataclass(frozen=True) +class _QuantCase: + name: str + spec: _Spec + clip_drop: float + + +def _cases() -> list[pytest.ParameterSet]: + cases = [ + _QuantCase("wan_fp8", WAN_SPEC, CLIP_DROP_FP8), + _QuantCase("hv_fp8", HV_SPEC, CLIP_DROP_FP8), + ] + params: list[pytest.ParameterSet] = [] + opted_in = os.environ.get(RUN_ENV, "").lower() in _TRUE + for case in cases: + marks: list[Any] = [] + if not opted_in: + marks.append(pytest.mark.skip(reason=f"Set {RUN_ENV}=1 to run video-gen quant accuracy.")) + if not os.environ.get(case.spec.bf16_env): + marks.append(pytest.mark.skip(reason=f"Set {case.spec.bf16_env} to run {case.name}.")) + if not os.environ.get(case.spec.fp8_env): + marks.append(pytest.mark.skip(reason=f"Set {case.spec.fp8_env} to run {case.name}.")) + params.append(pytest.param(case, id=case.name, marks=marks)) + return params + + +# --------------------------------------------------------------------------- +def _middle_frame(frames: list[Image.Image] | list[np.ndarray] | np.ndarray) -> Image.Image: + if isinstance(frames, np.ndarray): + idx = frames.shape[0] // 2 + arr = frames[idx] + if arr.dtype != np.uint8: + arr = (arr * 255).clip(0, 255).astype(np.uint8) + return Image.fromarray(arr).convert("RGB") + if not frames: + raise AssertionError("Empty video output") + mid = frames[len(frames) // 2] + if isinstance(mid, np.ndarray): + if mid.dtype != np.uint8: + mid = (mid * 255).clip(0, 255).astype(np.uint8) + return Image.fromarray(mid).convert("RGB") + return mid.convert("RGB") + + +def _extract_frames(result) -> list[Image.Image]: + """Pull a frame list out of an Omni text-to-video result.""" + from vllm_omni.entrypoints.omni import OmniRequestOutput + + if isinstance(result, list): + result = result[0] if result else None + if isinstance(result, OmniRequestOutput) and result.is_pipeline_output and result.request_output is not None: + result = result.request_output + images = getattr(result, "images", None) + if not images: + raise AssertionError("Pipeline output had no images") + if isinstance(images, list) and images and isinstance(images[0], tuple): + # (frames, fps) + images = images[0][0] + return list(images) + + +def _generate_video( + *, + model: str, + spec: _Spec, + quantization: str | None, + output_path: Path, +) -> tuple[Image.Image, float]: + """Generate one video and return its middle frame + elapsed wall time.""" + from vllm_omni.diffusion.data import DiffusionParallelConfig + from vllm_omni.entrypoints.omni import Omni + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + from vllm_omni.platforms import current_omni_platform + + kwargs: dict[str, Any] = dict( + model=model, + parallel_config=DiffusionParallelConfig(), + enforce_eager=True, + vae_use_tiling=True, + ) + if quantization: + kwargs["quantization"] = quantization + if spec.boundary_ratio is not None: + kwargs["boundary_ratio"] = spec.boundary_ratio + omni = Omni(**kwargs) + + try: + gen = torch.Generator(device=current_omni_platform.device_type).manual_seed(SEED) + sampling = dict( + height=HEIGHT, + width=WIDTH, + generator=gen, + guidance_scale=spec.guidance_scale, + num_inference_steps=NUM_INFERENCE_STEPS, + num_frames=NUM_FRAMES, + seed=SEED, + ) + if spec.guidance_scale_2 is not None: + sampling["guidance_scale_2"] = spec.guidance_scale_2 + + import time as _time + + t0 = _time.perf_counter() + out = omni.generate({"prompt": spec.prompt}, OmniDiffusionSamplingParams(**sampling)) + elapsed = _time.perf_counter() - t0 + + frames = _extract_frames(out) + mid = _middle_frame(frames) + mid.save(output_path) + return mid, elapsed + finally: + del omni + gc.collect() + if torch.cuda.is_available(): + torch.accelerator.empty_cache() + + +@pytest.mark.parametrize("case", _cases()) +@pytest.mark.skipif( + not torch.cuda.is_available() or torch.accelerator.device_count() < 1, + reason="Needs at least 1 GPU.", +) +def test_quantized_videogen_dit_matches_bf16_clip(case: _QuantCase, accuracy_artifact_root: Path) -> None: + """FP8 DiT should preserve prompt-aligned video quality vs BF16.""" + out_dir = model_output_dir(accuracy_artifact_root, case.spec.family + "-quant") + + with tempfile.TemporaryDirectory(): + bf16_model = os.environ[case.spec.bf16_env] + quant_model = os.environ[case.spec.fp8_env] + + bf16_frame, bf16_time = _generate_video( + model=bf16_model, + spec=case.spec, + quantization=None, + output_path=out_dir / "bf16.png", + ) + quant_frame, quant_time = _generate_video( + model=quant_model, + spec=case.spec, + quantization="fp8", + output_path=out_dir / f"{case.name}.png", + ) + + clip = CLIPScorer() + bf16_clip = clip.score(bf16_frame, case.spec.prompt) + quant_clip = clip.score(quant_frame, case.spec.prompt) + clip_drop = bf16_clip - quant_clip + speedup = (bf16_time / quant_time) if quant_time else float("nan") + + metrics = { + "case": case.name, + "family": case.spec.family, + "quantization": "fp8", + "bf16_model": bf16_model, + "quant_model": quant_model, + "prompt": case.spec.prompt, + "height": HEIGHT, + "width": WIDTH, + "num_frames": NUM_FRAMES, + "num_inference_steps": NUM_INFERENCE_STEPS, + "bf16_elapsed_s": bf16_time, + "quant_elapsed_s": quant_time, + "speedup_vs_bf16": speedup, + "bf16_clip_score": bf16_clip, + "quant_clip_score": quant_clip, + "clip_score_drop": clip_drop, + } + metrics_path = out_dir / f"{case.name}_metrics.json" + metrics_path.write_text(json.dumps(metrics, indent=2, sort_keys=True), encoding="utf-8") + + print(f"\n[{case.name}] BF16 {bf16_time:.2f}s | FP8 {quant_time:.2f}s (x{speedup:.2f})") + print(f" CLIP BF16={bf16_clip:.2f} FP8={quant_clip:.2f} drop={clip_drop:.2f}") + print(f" metrics={metrics_path}") + + assert quant_clip >= CLIP_ABSOLUTE_FLOOR, ( + f"{case.name} CLIP below floor: got {quant_clip:.2f}, expected >= {CLIP_ABSOLUTE_FLOOR:.2f}" + ) + assert clip_drop <= case.clip_drop, ( + f"{case.name} CLIP drop too large: got {clip_drop:.2f}, expected <= {case.clip_drop:.2f}" + ) diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index c9f81b64d..e83604fc5 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -433,8 +433,13 @@ class DiffusersPipelineLoader: # that have loaded weights tracking currently. if loaded_weights is not None: weights_not_loaded = weights_to_load - loaded_weights - # NOTE: if the model is quantized, ignore not_loaded check for scale weights - weights_scale_not_loaded = {name for name in weights_not_loaded if name.endswith("weight_scale")} + # NOTE: if the model is quantized, ignore not_loaded check for scale + # weights. ModelOpt FP8 carries a per-tensor `weight_scale` and a + # static activation `input_scale`, which the quant method may + # fold/track differently than plain parameters. + weights_scale_not_loaded = { + name for name in weights_not_loaded if name.endswith(("weight_scale", "input_scale")) + } weights_not_loaded = weights_not_loaded - weights_scale_not_loaded if weights_not_loaded: self._check_unloaded_weights(weights_not_loaded) diff --git a/vllm_omni/diffusion/models/hunyuan_video/hunyuan_video_15_transformer.py b/vllm_omni/diffusion/models/hunyuan_video/hunyuan_video_15_transformer.py index 1f06799c3..9af9e8488 100644 --- a/vllm_omni/diffusion/models/hunyuan_video/hunyuan_video_15_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_video/hunyuan_video_15_transformer.py @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Iterable -from typing import Any +from typing import TYPE_CHECKING, Any import torch import torch.nn.functional as F @@ -31,6 +31,9 @@ from vllm_omni.diffusion.forward_context import get_forward_context from vllm_omni.diffusion.layers.rope import RotaryEmbedding from vllm_omni.diffusion.models.flux.flux_transformer import FeedForward +if TYPE_CHECKING: + from vllm.model_executor.layers.quantization.base_config import QuantizationConfig + logger = init_logger(__name__) @@ -332,6 +335,8 @@ class HunyuanVideo15Attention(nn.Module): out_bias: bool = True, eps: float = 1e-6, out_dim: int | None = None, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", ): super().__init__() @@ -350,6 +355,8 @@ class HunyuanVideo15Attention(nn.Module): head_size=self.head_dim, total_num_heads=self.heads, bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.to_qkv", ) self.to_out = nn.ModuleList( @@ -360,6 +367,8 @@ class HunyuanVideo15Attention(nn.Module): bias=out_bias, input_is_parallel=True, return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_out.0", ), nn.Identity(), # placeholder for dropout (none used) ] @@ -374,6 +383,8 @@ class HunyuanVideo15Attention(nn.Module): head_size=self.head_dim, total_num_heads=self.heads, bias=added_proj_bias, + quant_config=quant_config, + prefix=f"{prefix}.add_kv_proj", ) self.to_add_out = RowParallelLinear( @@ -382,6 +393,8 @@ class HunyuanVideo15Attention(nn.Module): bias=out_bias, input_is_parallel=True, return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_add_out", ) self.rope = RotaryEmbedding(is_neox_style=False) @@ -401,6 +414,8 @@ class HunyuanVideo15Attention(nn.Module): image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, hidden_states_mask: torch.Tensor | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + # Ensure contiguous for FP8 quantized linear layers + hidden_states = hidden_states.contiguous() qkv, _ = self.to_qkv(hidden_states) q_size = self.to_qkv.num_heads * self.head_dim kv_size = self.to_qkv.num_kv_heads * self.head_dim @@ -421,6 +436,7 @@ class HunyuanVideo15Attention(nn.Module): key = self.rope(key, cos, sin) if encoder_hidden_states is not None: + encoder_hidden_states = encoder_hidden_states.contiguous() encoder_qkv, _ = self.add_kv_proj(encoder_hidden_states) add_q_size = self.add_kv_proj.num_heads * self.head_dim add_kv_size = self.add_kv_proj.num_kv_heads * self.head_dim @@ -492,6 +508,8 @@ class HunyuanVideo15TransformerBlock(nn.Module): attention_head_dim: int, mlp_ratio: float = 4.0, qk_norm: str = "rms_norm", + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", ) -> None: super().__init__() hidden_size = num_attention_heads * attention_head_dim @@ -507,13 +525,23 @@ class HunyuanVideo15TransformerBlock(nn.Module): out_dim=hidden_size, bias=True, eps=1e-6, + quant_config=quant_config, + prefix=f"{prefix}.attn", ) self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) - self.ff = FeedForward(dim=hidden_size, dim_out=hidden_size, mult=mlp_ratio) + self.ff = FeedForward( + dim=hidden_size, dim_out=hidden_size, mult=mlp_ratio, quant_config=quant_config, prefix=f"{prefix}.ff" + ) self.norm2_context = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) - self.ff_context = FeedForward(dim=hidden_size, dim_out=hidden_size, mult=mlp_ratio) + self.ff_context = FeedForward( + dim=hidden_size, + dim_out=hidden_size, + mult=mlp_ratio, + quant_config=quant_config, + prefix=f"{prefix}.ff_context", + ) def forward( self, @@ -601,6 +629,7 @@ class HunyuanVideo15Transformer3DModel(nn.Module): target_size: int = 640, task_type: str = "i2v", use_meanflow: bool = False, + quant_config: "QuantizationConfig | None" = None, ): super().__init__() @@ -632,9 +661,14 @@ class HunyuanVideo15Transformer3DModel(nn.Module): self.transformer_blocks = nn.ModuleList( [ HunyuanVideo15TransformerBlock( - num_attention_heads, attention_head_dim, mlp_ratio=mlp_ratio, qk_norm=qk_norm + num_attention_heads, + attention_head_dim, + mlp_ratio=mlp_ratio, + qk_norm=qk_norm, + quant_config=quant_config, + prefix=f"transformer_blocks.{i}", ) - for _ in range(num_layers) + for i in range(num_layers) ] ) diff --git a/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5.py b/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5.py index 038898a16..0684f6749 100644 --- a/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5.py +++ b/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5.py @@ -143,7 +143,9 @@ class HunyuanVideo15Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, Diff self.scheduler._shift = od_config.flow_shift transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, HunyuanVideo15Transformer3DModel) - self.transformer = HunyuanVideo15Transformer3DModel(od_config=od_config, **transformer_kwargs) + self.transformer = HunyuanVideo15Transformer3DModel( + od_config=od_config, quant_config=od_config.quantization_config, **transformer_kwargs + ) # Check if model uses meanflow (distilled variants) self.use_meanflow = getattr(od_config.tf_model_config, "use_meanflow", False) diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_video_15_dit_fp8.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_video_15_dit_fp8.yaml new file mode 100644 index 000000000..097afc020 --- /dev/null +++ b/vllm_omni/model_executor/stage_configs/hunyuan_video_15_dit_fp8.yaml @@ -0,0 +1,38 @@ +# Stage config for running HunyuanVideo-1.5 DiT with ModelOpt FP8 auto-detect. +# Single GPU. Bump `tensor_parallel_size` and `devices` for multi-GPU TP. +# +# Use with a ModelOpt FP8 checkpoint (e.g. produced by +# examples/quantization/quantize_hunyuanvideo_15_modelopt_fp8.py). + +stage_args: + - stage_id: 0 + stage_type: diffusion + runtime: + devices: "0" + max_batch_size: 1 + engine_args: + model_stage: dit + model_class_name: HunyuanVideo15Pipeline + max_num_seqs: 1 + enforce_eager: true + trust_remote_code: true + distributed_executor_backend: "mp" + # HunyuanVideo-1.5 peak VRAM is dominated by the single-shot VAE decode of + # the full video (the DiT contributes little), so tiling the decode is the + # main memory lever — far more than FP8 (e.g. 480x832x33: ~117 -> ~47 GiB). + # Default it on here; override with --vae-use-tiling/false if you have headroom. + vae_use_tiling: true + parallel_config: + tensor_parallel_size: 1 + + final_output: true + final_output_type: video + is_comprehension: false + default_sampling_params: + seed: 42 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 1 diff --git a/vllm_omni/model_executor/stage_configs/wan2_2_ti2v_dit_fp8.yaml b/vllm_omni/model_executor/stage_configs/wan2_2_ti2v_dit_fp8.yaml new file mode 100644 index 000000000..1b1c40537 --- /dev/null +++ b/vllm_omni/model_executor/stage_configs/wan2_2_ti2v_dit_fp8.yaml @@ -0,0 +1,41 @@ +# Stage config for running Wan2.2 TI2V-5B DiT with ModelOpt FP8 auto-detect. +# Single GPU (TI2V-5B fits 80GB BF16; FP8 drops by ~half). +# For the A14B MoE variants, bump `tensor_parallel_size` and `devices`. +# +# Use with a ModelOpt FP8 checkpoint (e.g. produced by +# examples/quantization/quantize_wan2_2_modelopt_fp8.py). + +stage_args: + - stage_id: 0 + stage_type: diffusion + runtime: + devices: "0" + max_batch_size: 1 + engine_args: + model_stage: dit + # Wan2.2 TI2V-5B is served by the base Wan22Pipeline, which auto-detects + # expand_timesteps (TI2V-5B mode) from model_index.json. (The standalone + # Wan22TI2VPipeline was consolidated into Wan22Pipeline upstream.) + model_class_name: Wan22Pipeline + max_num_seqs: 1 + enforce_eager: true + trust_remote_code: true + distributed_executor_backend: "mp" + # Tile the VAE decode by default. Wan2.2's 480p peak is DiT-dominated (so + # FP8 is the main lever here), but tiling adds headroom for higher + # resolutions / longer clips at negligible cost. Override with --vae-use-tiling/false. + vae_use_tiling: true + parallel_config: + tensor_parallel_size: 1 + + final_output: true + final_output_type: video + is_comprehension: false + default_sampling_params: + seed: 42 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 1 diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py index 29cc56e0f..c343276b1 100644 --- a/vllm_omni/patch.py +++ b/vllm_omni/patch.py @@ -217,3 +217,43 @@ def _patch_scaled_mm_fp8_contiguous_activation(): _patch_scaled_mm_fp8_contiguous_activation() + + +def _patch_flashinfer_fp8_scaled_mm_output_shape(): + """Restore the N-D output shape for the FlashInfer FP8 ScaledMM kernel. + + ``FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm`` returns the raw 2-D + GEMM result and ignores ``output_shape``, unlike the CUTLASS / PyTorch + ScaledMM kernels which reshape to it. A 3-D activation ``(B, S, D)`` thus + collapses to ``(B*S, D)``, breaking diffusion DiTs that reshape the linear + output by absolute dim (e.g. Wan2.2 ``qkv.unflatten(2, ...)``) with + ``IndexError: Dimension out of range``. It only bites >2-D inputs, so LLM + (token-flattened, 2-D) paths are unaffected. + + Carried here because the upstream fix may not have landed yet; this override + becomes a harmless no-op once vLLM honors ``output_shape`` itself. + """ + try: + from vllm.model_executor.kernels.linear.scaled_mm.flashinfer import ( + FlashInferFP8ScaledMMLinearKernel, + ) + except ImportError: + return + + _original_apply_scaled_mm = FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm + if getattr(_original_apply_scaled_mm, "_omni_output_shape_patched", False): + return + + def _patched_apply_scaled_mm(self, *, A, B, out_dtype, As, Bs, bias, output_shape): # noqa: N803 + out = _original_apply_scaled_mm( + self, A=A, B=B, out_dtype=out_dtype, As=As, Bs=Bs, bias=bias, output_shape=output_shape + ) + if tuple(out.shape) != tuple(output_shape): + out = out.view(*output_shape) + return out + + _patched_apply_scaled_mm._omni_output_shape_patched = True + FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm = _patched_apply_scaled_mm + + +_patch_flashinfer_fp8_scaled_mm_output_shape() From eb1b782dd15bef20a5a762614fb56e77abc4a8c0 Mon Sep 17 00:00:00 2001 From: zzh <67720593+BLANKETusers@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:58:44 +0800 Subject: [PATCH 21/75] [BugFix] fix hunyuan image3 offline cot (#4174) Signed-off-by: zzh <943967662@qq.com> Signed-off-by: zengchuang Co-authored-by: zengchuang Co-authored-by: Canlin Guo --- .../model_executor/models/hunyuan_image3/hunyuan_image3.py | 1 + vllm_omni/platforms/npu/worker/npu_model_runner.py | 1 + vllm_omni/worker/gpu_model_runner.py | 5 +++++ 3 files changed, 7 insertions(+) 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 7b975796f..8c983cff1 100755 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -1457,6 +1457,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo HunyuanImage3Inputs: TypeAlias = HunyuanImage3PixelInputs prefer_model_sampler = True + logitsprocs_need_output_token_ids = True # Siglip2 ViT supports data-parallel encoding (mm_encoder_tp_mode="data"): # weights are replicated and the image batch is sharded across TP ranks. diff --git a/vllm_omni/platforms/npu/worker/npu_model_runner.py b/vllm_omni/platforms/npu/worker/npu_model_runner.py index 4137ffe92..3108b68a8 100644 --- a/vllm_omni/platforms/npu/worker/npu_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_model_runner.py @@ -33,6 +33,7 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner): # in _pad_for_sequence_parallelism during execute_model. # This is a workaround for vllm-ascend not passing vllm_config to enable_sp(). enable_sp(self.vllm_config) + self._maybe_enable_output_token_ids_for_model_sampler() self._init_talker_mtp() @torch.inference_mode() diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index e74997618..70f1f58dc 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -143,9 +143,14 @@ class OmniGPUModelRunner(GPUModelRunner): @instrument(span_name="Loading (GPU)") def load_model(self, *args, **kwargs) -> None: super().load_model(*args, **kwargs) + self._maybe_enable_output_token_ids_for_model_sampler() self._init_talker_mtp() self._prewarm_attention_capture_workspaces() + def _maybe_enable_output_token_ids_for_model_sampler(self) -> None: + if getattr(self.model, "logitsprocs_need_output_token_ids", False): + self.input_batch.logitsprocs_need_output_token_ids = True + def _init_talker_mtp(self) -> None: # TODO move this model specific logic to a separate class # TTS model IS the talker (no .talker sub-attr); use getattr to support both Omni and TTS. From 5f1a876cbda694c1f5a90a3a56ecc6468f04632b Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:36:09 +0800 Subject: [PATCH 22/75] [skip ci] docs: update WeChat QR code (#4242) Signed-off-by: David Chen <530634352@qq.com> --- docs/assets/WeChat.jpg | Bin 418087 -> 412585 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index c4e836f68e534d3581d8b189c4f0e0a21e8390b1..2596240c1d13ee6ec24e281e34d284f864ccb36f 100644 GIT binary patch delta 340772 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%0Anz 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

bXToU^LachqL$ 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/pipeline.yaml`` by trying - (a) the raw ``model_type`` as the directory name, then - (b) ``model_type`` with hyphens replaced by underscores, - and finally (c) scanning every ``pipeline.yaml`` for one that - declares a matching ``model_type`` or ``hf_architectures``. - - Returns None if no pipeline.yaml is found — caller handles the - ``resolve_model_config_path`` fallback via stage_configs/ YAMLs. - """ - model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code) - if model_type is None: - return None - - # Direct lookups by convention - candidates = [model_type, model_type.replace("-", "_")] - for dir_name in candidates: - pipeline_path = get_pipeline_path(dir_name, "pipeline.yaml") - if pipeline_path.exists(): - return cls._parse_pipeline_yaml(pipeline_path, model_type) - - # Scan fallback: read every pipeline.yaml and match on declared fields - hf_archs = set(getattr(hf_config, "architectures", []) or []) if hf_config else set() - if _MODELS_DIR.exists(): - for subdir in sorted(_MODELS_DIR.iterdir()): - if not subdir.is_dir(): - continue - pipeline_path = subdir / "pipeline.yaml" - if not pipeline_path.exists(): - continue - try: - cfg = load_yaml_config(pipeline_path) - except Exception as exc: - logger.debug("Skip %s: %s", pipeline_path, exc) - continue - declared_type = getattr(cfg, "model_type", None) - declared_archs = set(getattr(cfg, "hf_architectures", None) or []) - if declared_type == model_type or (hf_archs and hf_archs.intersection(declared_archs)): - return cls._parse_pipeline_yaml(pipeline_path, declared_type or model_type) - - logger.debug("No pipeline.yaml found for model_type %s (archs=%s)", model_type, sorted(hf_archs)) - return None - - # Keys consumed as explicit StageConfig fields — everything else is - # passed through via yaml_extras. - _KNOWN_STAGE_KEYS: set[str] = { - "stage_id", - "model_stage", - "stage_type", - "input_sources", - "engine_input_source", - "custom_process_input_func", - "final_output", - "final_output_type", - "worker_type", - "scheduler_cls", - "hf_config_name", - "is_comprehension", - "engine_args", - "runtime", - } - - @classmethod - def _parse_pipeline_yaml(cls, path: Path, model_type: str) -> ModelPipeline: - """Parse a pipeline YAML file. - - Args: - path: Path to the YAML file. - model_type: Model type identifier. - - Returns: - ModelPipeline object. - """ - config_data = load_yaml_config(path) - - stages: list[StageConfig] = [] - for stage_data in config_data.stages: - # Use .get() for optional fields — idiomatic for OmegaConf DictConfig - stage_type_str = stage_data.get("stage_type", "llm") - stage_type = StageType(stage_type_str) if stage_type_str else StageType.LLM - - # Handle both 'input_sources' (new) and 'engine_input_source' (legacy) - input_sources = stage_data.get("input_sources", None) - if input_sources is None: - input_sources = stage_data.get("engine_input_source", []) - if input_sources is None: - input_sources = [] - input_sources = list(input_sources) - - # Extract per-stage engine_args and runtime dicts - raw_ea = stage_data.get("engine_args", None) - yaml_engine_args = to_dict(raw_ea) if raw_ea is not None else {} - raw_rt = stage_data.get("runtime", None) - yaml_runtime = to_dict(raw_rt) if raw_rt is not None else {} - - # Migrate legacy runtime.max_batch_size → engine_args.max_num_seqs - if "max_batch_size" in yaml_runtime: - mbs = yaml_runtime.pop("max_batch_size") - yaml_engine_args.setdefault("max_num_seqs", int(mbs)) - logger.debug( - "Stage %s: migrated runtime.max_batch_size=%s to engine_args.max_num_seqs", - stage_data.get("stage_id", "?"), - mbs, - ) - - # Topology-level fields that also live inside engine_args in legacy - # YAMLs (worker_type, scheduler_cls, etc.) — read from both places. - worker_type = stage_data.get("worker_type", None) or yaml_engine_args.pop("worker_type", None) - scheduler_cls = stage_data.get("scheduler_cls", None) or yaml_engine_args.pop("scheduler_cls", None) - if scheduler_cls: - async_sched = yaml_engine_args.get("async_scheduling") - if async_sched is not None: - logger.warning( - "Stage %s: async_scheduling=%r and scheduler_cls=%r " - "should not be set together. scheduler_cls will take " - "precedence for which scheduler is used.", - stage_data.stage_id, - async_sched, - scheduler_cls, - ) - else: - logger.warning( - "Stage %s: scheduler_cls=%r is deprecated. Use async_scheduling instead.", - stage_data.stage_id, - scheduler_cls, - ) - hf_config_name = stage_data.get("hf_config_name", None) or yaml_engine_args.pop("hf_config_name", None) - model_stage = getattr(stage_data, "model_stage", None) or yaml_engine_args.pop("model_stage", None) - - # Collect pass-through fields (default_sampling_params, - # output_connectors, input_connectors, tts_args, etc.) - yaml_extras: dict[str, Any] = {} - for key in stage_data: - if key not in cls._KNOWN_STAGE_KEYS: - val = stage_data[key] - try: - yaml_extras[key] = to_dict(val) - except ValueError: - yaml_extras[key] = val - - stage = StageConfig( - stage_id=stage_data.stage_id, - model_stage=model_stage or "", - stage_type=stage_type, - input_sources=input_sources, - custom_process_input_func=stage_data.get("custom_process_input_func", None), - final_output=stage_data.get("final_output", False), - final_output_type=stage_data.get("final_output_type", None), - worker_type=worker_type, - scheduler_cls=scheduler_cls, - hf_config_name=hf_config_name, - is_comprehension=stage_data.get("is_comprehension", False), - yaml_engine_args=yaml_engine_args, - yaml_runtime=yaml_runtime, - yaml_extras=yaml_extras, - ) - stages.append(stage) - - # Get pipeline-wide flags - async_chunk = config_data.get("async_chunk", False) - - # Get optional connector config — check both top-level and nested - # under ``runtime`` (legacy stage_configs format). - connectors = None - edges = None - if hasattr(config_data, "connectors"): - connectors = to_dict(config_data.connectors) - if hasattr(config_data, "edges"): - edges = to_dict(config_data.edges) - if hasattr(config_data, "runtime") and config_data.runtime is not None: - top_runtime = config_data.runtime - if connectors is None and hasattr(top_runtime, "connectors"): - connectors = to_dict(top_runtime.connectors) - if edges is None and hasattr(top_runtime, "edges"): - edges = to_dict(top_runtime.edges) - - return ModelPipeline( - model_type=getattr(config_data, "model_type", model_type), - stages=stages, - async_chunk=async_chunk, - connectors=connectors, - edges=edges, - ) - @classmethod def _auto_detect_model_type(cls, model: str, trust_remote_code: bool = True) -> tuple[str | None, Any]: """Auto-detect model_type from model directory. diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 5744c6faa..9887a0e50 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -610,9 +610,6 @@ class OmniDiffusionConfig: # Step mode settings step_execution: bool = False - # sleep mode - enable_sleep_mode: bool = False - # Maximum number of sequences to generate in a batch max_num_seqs: int = 1 From 7dcbeaea037b52330315853f226228062e4172fb Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Mon, 8 Jun 2026 21:55:32 -0600 Subject: [PATCH 30/75] [Core] Cleanup DiffusersPipelineLoader (#1932) Signed-off-by: Alex Brooks --- docs/design/module/dit_module.md | 4 +- .../model_loader/test_diffusers_loader.py | 70 ++++++- .../test_diffusers_loader_gguf.py | 61 +++--- tests/diffusion/test_worker_wrapper_base.py | 2 +- .../cache/teacache/coefficient_estimator.py | 2 +- .../model_loader/diffusers_loader.py | 192 +++++++++--------- .../worker/diffusion_model_runner.py | 5 +- 7 files changed, 190 insertions(+), 146 deletions(-) diff --git a/docs/design/module/dit_module.md b/docs/design/module/dit_module.md index 009df77ff..50890c597 100644 --- a/docs/design/module/dit_module.md +++ b/docs/design/module/dit_module.md @@ -305,8 +305,8 @@ class GPUWorker: ) # Load model - model_loader = DiffusersPipelineLoader(load_config) - self.pipeline = model_loader.load_model(od_config, load_device=f"cuda:{rank}") + model_loader = DiffusersPipelineLoader(load_config, self.od_config) + self.pipeline = model_loader.load_model(load_device=f"cuda:{rank}") # Setup cache backend from vllm_omni.diffusion.cache.selector import get_cache_backend diff --git a/tests/diffusion/model_loader/test_diffusers_loader.py b/tests/diffusion/model_loader/test_diffusers_loader.py index 9434702f9..376dbe685 100644 --- a/tests/diffusion/model_loader/test_diffusers_loader.py +++ b/tests/diffusion/model_loader/test_diffusers_loader.py @@ -1,19 +1,46 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Tests for the DiffusersPipelineLoader. +""" + from types import SimpleNamespace import pytest import torch import torch.nn as nn +from huggingface_hub import snapshot_download +from vllm.config.load import LoadConfig from vllm_omni.diffusion.config import get_current_diffusion_config, get_current_diffusion_config_or_none +from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter +from vllm_omni.diffusion.models.helios import HeliosPipeline from vllm_omni.diffusion.registry import initialize_model pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] +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) + + +@pytest.fixture(scope="function") +def mock_tp_group(mocker): + """Mocks the tensor parallel group; this is needed to initialize the Helios model.""" + mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1) + mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0) + mock_group = mocker.MagicMock() + mock_group.world_size = 1 + mock_group.rank_in_group = 0 + mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_group) + class _DummyPipelineModel(nn.Module): def __init__(self, *, source_prefix: str): @@ -42,7 +69,13 @@ class _DummyPipelineModel(nn.Module): def _make_loader_with_weights(weight_names: list[str]) -> DiffusersPipelineLoader: - loader = object.__new__(DiffusersPipelineLoader) + od_config = SimpleNamespace( + dtype=torch.float32, + parallel_config=SimpleNamespace(use_hsdp=False), + quantization_config=None, + ) + loader = DiffusersPipelineLoader(LoadConfig(), od_config) + loader.counter_before_loading_weights = 0.0 loader.counter_after_loading_weights = 0.0 @@ -150,16 +183,15 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch): quantization_config=None, ) - loader = object.__new__(DiffusersPipelineLoader) + loader = DiffusersPipelineLoader(LoadConfig(), od_config) loader.load_weights = lambda model: None # type: ignore[assignment] loader._process_weights_after_loading = lambda model, target_device: None # type: ignore[assignment] - loader._is_gguf_quantization = lambda _od_config: False # type: ignore[assignment] + loader._is_gguf_quantization = lambda: False # type: ignore[assignment] monkeypatch.setattr(loader_mod, "resolve_obj_by_qualname", lambda _name: _ConfigAwareModel) monkeypatch.setattr(loader_mod.torch, "device", lambda _name: _DeviceContext("cpu")) model = loader.load_model( - od_config, load_device="cpu", load_format="custom_pipeline", custom_pipeline_name="tests.dummy.ConfigAwarePipeline", @@ -168,3 +200,33 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch): assert model.captured_config is od_config assert model.seen_config_during_init is od_config assert get_current_diffusion_config_or_none() is None + + +def test_get_all_weights(prefetch_helios_model, mock_tp_group): + """Ensure that get all weights on a tiny model resolves to nonempty weights.""" + od_config = OmniDiffusionConfig( + model_class_name="HeliosPipeline", + model=model_path, + ) + loader = DiffusersPipelineLoader( + load_config=LoadConfig(), + od_config=od_config, + ) + pipeline = HeliosPipeline(od_config=od_config) + + weights = list(loader.get_all_weights(pipeline)) + assert len(weights) > 0 + + +def test_load_model(prefetch_helios_model, mock_tp_group): + """Ensure that load model creates an instance of the expected pipeline class.""" + od_config = OmniDiffusionConfig( + model_class_name="HeliosPipeline", + model=model_path, + ) + loader = DiffusersPipelineLoader( + load_config=LoadConfig(), + od_config=od_config, + ) + model = loader.load_model(load_device="cpu") + assert isinstance(model, HeliosPipeline) diff --git a/tests/diffusion/model_loader/test_diffusers_loader_gguf.py b/tests/diffusion/model_loader/test_diffusers_loader_gguf.py index 9aae9135e..f747dca81 100644 --- a/tests/diffusion/model_loader/test_diffusers_loader_gguf.py +++ b/tests/diffusion/model_loader/test_diffusers_loader_gguf.py @@ -62,6 +62,7 @@ def _make_loader() -> DiffusersPipelineLoader: ignore_patterns=["*.tmp"], ) loader.od_config = None + loader.quant_config = None loader.counter_before_loading_weights = 0.0 loader.counter_after_loading_weights = 0.0 return loader @@ -69,53 +70,47 @@ def _make_loader() -> DiffusersPipelineLoader: def test_is_gguf_quantization_accepts_dict_config(): loader = _make_loader() - od_config = SimpleNamespace( - quantization_config={ - "method": "gguf", - "gguf_model": "weights.gguf", - } - ) + loader.quant_config = { + "method": "gguf", + "gguf_model": "weights.gguf", + } - assert loader._is_gguf_quantization(od_config) is True + assert loader._is_gguf_quantization() is True def test_is_gguf_quantization_rejects_non_gguf_dict_config(): loader = _make_loader() - od_config = SimpleNamespace( - quantization_config={ - "method": "fp8", - "gguf_model": "weights.gguf", - } - ) + loader.quant_config = { + "method": "fp8", + "gguf_model": "weights.gguf", + } - assert loader._is_gguf_quantization(od_config) is False + assert loader._is_gguf_quantization() is False def test_is_gguf_quantization_requires_gguf_model_for_dict_config(): loader = _make_loader() - od_config = SimpleNamespace(quantization_config={"method": "gguf"}) + loader.quant_config = {"method": "gguf"} with pytest.raises(ValueError, match="gguf_model"): - loader._is_gguf_quantization(od_config) + loader._is_gguf_quantization() def test_is_gguf_quantization_accepts_object_config(): loader = _make_loader() - od_config = SimpleNamespace( - quantization_config=SimpleNamespace( - get_name=lambda: "gguf", - gguf_model="weights.gguf", - ) + loader.quant_config = SimpleNamespace( + get_name=lambda: "gguf", + gguf_model="weights.gguf", ) - assert loader._is_gguf_quantization(od_config) is True + assert loader._is_gguf_quantization() is True def test_is_gguf_quantization_uses_fallback_object_without_get_name(): loader = _make_loader() - od_config = SimpleNamespace(quantization_config=SimpleNamespace(gguf_model="weights.gguf")) + loader.quant_config = SimpleNamespace(gguf_model="weights.gguf") - assert loader._is_gguf_quantization(od_config) is True + assert loader._is_gguf_quantization() is True def test_get_model_loadable_names_collects_parameters_and_buffers(): @@ -171,16 +166,14 @@ def test_resolve_gguf_model_path_downloads_by_quant_type(monkeypatch: pytest.Mon def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.MonkeyPatch): loader = _make_loader() + loader.quant_config = SimpleNamespace(gguf_model="weights.gguf") + loader.od_config = SimpleNamespace(revision="main") source = DiffusersPipelineLoader.ComponentSource( model_or_path="dummy", subfolder="transformer", revision=None, prefix="transformer.", ) - od_config = SimpleNamespace( - revision="main", - quantization_config=SimpleNamespace(gguf_model="weights.gguf"), - ) class _Adapter: def weights_iterator(self): @@ -190,7 +183,7 @@ def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.Mon monkeypatch.setattr(loader, "_resolve_gguf_model_path", lambda gguf_model, revision: "resolved.gguf") monkeypatch.setattr(loader_module, "get_gguf_adapter", lambda gguf_file, model, source, od_config: _Adapter()) - weights = list(loader._get_gguf_weights_iterator(source, object(), od_config)) + weights = list(loader._get_gguf_weights_iterator(source, object())) assert weights[0][0] == "transformer.weight" assert torch.equal(weights[0][1], torch.ones((2, 2))) @@ -201,13 +194,12 @@ def test_get_gguf_weights_iterator_prefixes_source_names(monkeypatch: pytest.Mon def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights(monkeypatch: pytest.MonkeyPatch): loader = _make_loader() model = _DummyModel() - od_config = SimpleNamespace() monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources)) monkeypatch.setattr( loader, "_get_gguf_weights_iterator", - lambda source, model, od_config: iter([("transformer.weight", torch.ones((2, 2), dtype=torch.float32))]), + lambda source, model: iter([("transformer.weight", torch.ones((2, 2), dtype=torch.float32))]), ) def _hf_iter(source): @@ -222,7 +214,7 @@ def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights( lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"}, ) - loaded = loader._load_weights_with_gguf(model, od_config) + loaded = loader._load_weights_with_gguf(model) assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"} assert model.calls[0] == ["transformer.weight"] @@ -233,13 +225,12 @@ def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights( def test_load_weights_with_gguf_skips_transformer_fallback_when_gguf_is_complete(monkeypatch: pytest.MonkeyPatch): loader = _make_loader() model = _DummyModel() - od_config = SimpleNamespace() monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources)) monkeypatch.setattr( loader, "_get_gguf_weights_iterator", - lambda source, model, od_config: iter( + lambda source, model: iter( [ ("transformer.weight", torch.ones((2, 2), dtype=torch.float32)), ("transformer.bias", torch.zeros(2, dtype=torch.float32)), @@ -262,7 +253,7 @@ def test_load_weights_with_gguf_skips_transformer_fallback_when_gguf_is_complete lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"}, ) - loaded = loader._load_weights_with_gguf(model, od_config) + loaded = loader._load_weights_with_gguf(model) assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"} assert hf_calls == ["vae"] diff --git a/tests/diffusion/test_worker_wrapper_base.py b/tests/diffusion/test_worker_wrapper_base.py index 502a923ba..fa26ae276 100644 --- a/tests/diffusion/test_worker_wrapper_base.py +++ b/tests/diffusion/test_worker_wrapper_base.py @@ -41,7 +41,7 @@ def mock_od_config(mocker: MockerFixture): config.cache_backend = None config.cache_config = None config.model = "test-model" - config.diffusion_load_format = None + config.diffusion_load_format = "default" config.dtype = "float32" config.max_cpu_loras = 0 config.lora_path = None diff --git a/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py b/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py index f808b301b..60638cca8 100644 --- a/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py +++ b/vllm_omni/diffusion/cache/teacache/coefficient_estimator.py @@ -81,7 +81,7 @@ class DefaultAdapter: loader = DiffusersPipelineLoader(LoadConfig(), od_config=od_config) # load_model will handle dtypes / device placement, put in .eval() mode - return loader.load_model(od_config=od_config, load_device=device) + return loader.load_model(load_device=device) @staticmethod def get_transformer(pipeline: Any) -> tuple[Any, str]: diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index e83604fc5..e5ef5e319 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import contextlib import dataclasses import glob import os @@ -30,8 +31,9 @@ from vllm.transformers_utils.repo_utils import file_exists from vllm.utils.import_utils import resolve_obj_by_qualname from vllm.utils.torch_utils import set_default_torch_dtype +from vllm_omni.diffusion.config import set_current_diffusion_config from vllm_omni.diffusion.data import OmniDiffusionConfig -from vllm_omni.diffusion.distributed.hsdp import HSDPInferenceConfig +from vllm_omni.diffusion.distributed.hsdp import HSDPInferenceConfig, apply_hsdp_to_model from vllm_omni.diffusion.model_loader.checkpoint_adapters import ( get_checkpoint_adapter, ) @@ -100,18 +102,20 @@ class DiffusersPipelineLoader: counter_before_loading_weights: float = 0.0 counter_after_loading_weights: float = 0.0 - def __init__(self, load_config: LoadConfig, od_config: OmniDiffusionConfig | None = None): + def __init__(self, load_config: LoadConfig, od_config: OmniDiffusionConfig): self.load_config = load_config self.od_config = od_config + self.quant_config = od_config.quantization_config + self.parallel_config = od_config.parallel_config def _prepare_weights( self, - model_name_or_path: Path, + model_name_or_path: Path | str, subfolder: str | None, revision: str | None, fall_back_to_pt: bool, allow_patterns_overrides: list[str] | None, - ) -> tuple[str, list[str], bool]: + ) -> tuple[Path | str, list[str], bool]: """Prepare weights for the model. If the model is not local, it will be downloaded.""" @@ -208,15 +212,13 @@ class DiffusersPipelineLoader: source.allow_patterns_overrides, ) - od_config = self.od_config use_multithread = ( use_safetensors - and od_config is not None - and getattr(od_config, "enable_multithread_weight_load", False) + and getattr(self.od_config, "enable_multithread_weight_load", False) and self.load_config.safetensors_load_strategy != "torchao" ) if use_multithread: - num_threads = getattr(od_config, "num_weight_load_threads", 4) + num_threads = getattr(self.od_config, "num_weight_load_threads", 4) # Keep deterministic shard order before passing to vLLM helper. sorted_hf_weights_files = sorted(hf_weights_files, key=_natural_sort_key) weights_iterator = multi_thread_safetensors_weights_iterator( @@ -242,10 +244,7 @@ class DiffusersPipelineLoader: return prefixed_weights_iterator def _get_source_quant_config(self, source: "ComponentSource") -> object | None: - if self.od_config is None: - return None - - quant_config = self.od_config.quantization_config + quant_config = self.quant_config if hasattr(quant_config, "resolve"): return quant_config.resolve(source.prefix.rstrip(".")) return quant_config @@ -308,7 +307,6 @@ class DiffusersPipelineLoader: def load_model( self, - od_config: OmniDiffusionConfig, load_device: str, load_format: str | None = "default", custom_pipeline_name: str | type[nn.Module] | None = None, @@ -317,14 +315,13 @@ class DiffusersPipelineLoader: """Load a model with the given configurations.""" if load_format is None: load_format = "default" - self.od_config = od_config # CPU offload + quantization: for offline-quantized models (e.g., AutoRound MXFP8), # weights are already quantized in the checkpoint — load directly on CPU. # For online quantization, load on device so quantization can run on accelerator, # then move back to CPU afterward. offload_after_quant = False - if load_device == "cpu" and od_config.quantization_config is not None: - quant_cfg = od_config.quantization_config + if load_device == "cpu" and self.quant_config is not None and device is not None: + quant_cfg = self.quant_config is_offline = getattr(quant_cfg, "data_type", None) == "mx_fp" or getattr( quant_cfg, "is_checkpoint_quantized", False ) @@ -339,48 +336,21 @@ class DiffusersPipelineLoader: logger.info("Offline-quantized model with CPU offload, loading weights directly on CPU") target_device = torch.device(load_device) - with set_default_torch_dtype(od_config.dtype): - if od_config.parallel_config.use_hsdp: + with set_default_torch_dtype(self.od_config.dtype): + if self.parallel_config.use_hsdp: model = self._load_model_with_hsdp( - od_config, target_device=device, load_format=load_format, custom_pipeline_name=custom_pipeline_name + target_device=device, load_format=load_format, custom_pipeline_name=custom_pipeline_name ) else: - if load_format == "custom_pipeline": - # NOTE: Custom pipelines call HuggingFace `from_pretrained(...).to(device)` - # internally. If we construct them under `with target_device:` (CUDA), - # safetensors takes a direct-to-GPU fast path that calls `cudaMalloc` - # via the driver API and BYPASSES PyTorch's caching allocator. - # That makes those bytes invisible to CuMemAllocator, so `sleep()` - # cannot offload/unmap them and GPU memory stays pinned. - # - # Fix: build the custom pipeline on CPU first (no default device - # context), then explicitly move it to the target device. The - # subsequent `.to(target_device)` issues `torch.empty(..., device=cuda)` - # + `copy_`, which goes through the caching allocator and is fully - # tracked by CuMemAllocator. - from vllm_omni.diffusion.config import set_current_diffusion_config - - model_cls = _resolve_custom_pipeline_cls(custom_pipeline_name) - with set_current_diffusion_config(od_config): - model = model_cls(od_config=od_config) - if target_device.type != "cpu": - model.to(target_device) - else: - with target_device: - if load_format == "default": - model = initialize_model(od_config) - elif load_format == "diffusers": - model = DiffusersAdapterPipeline(od_config=od_config, device=target_device) - else: - raise ValueError(f"Unknown load_format: {load_format}") + model = self._init_from_load_format(load_format, target_device, custom_pipeline_name, is_hsdp=False) logger.debug("Loading weights on %s ...", load_device) if load_format == "diffusers": # DiffusersAdapterPipeline.load_weights() calls # DiffusionPipeline.from_pretrained() internally; it does # not use our native customized pipeline classes. cast(DiffusersAdapterPipeline, model).load_weights() - elif self._is_gguf_quantization(od_config): - self._load_weights_with_gguf(model, od_config) + elif self._is_gguf_quantization(): + self._load_weights_with_gguf(model) else: # Quantization does not happen in `load_weights` but after it self.load_weights(model) @@ -403,7 +373,7 @@ class DiffusersPipelineLoader: """ for _, module in model.named_modules(): quant_method = getattr(module, "quant_method", None) - if isinstance(quant_method, QuantizeMethodBase): + if quant_method is not None and isinstance(quant_method, QuantizeMethodBase): # Move module to target device for processing if needed module_device = next(module.parameters(), None) if module_device is not None: @@ -481,9 +451,11 @@ class DiffusersPipelineLoader: are logged as a warning. Any *other* missing weight raises ``ValueError`` regardless of quantization. """ - od_config = getattr(self, "od_config", None) - if od_config is None or od_config.quantization_config is None: - raise ValueError(f"Following weights were not initialized from checkpoint: {weights_not_loaded}") + if self.quant_config is None: + raise ValueError( + "The quantization config is None, and the following weights " + f"were not initialized from checkpoint: {weights_not_loaded}" + ) expected_missing = {w for w in weights_not_loaded if self._is_expected_quantized_weight(w)} unexpected_missing = weights_not_loaded - expected_missing @@ -496,39 +468,33 @@ class DiffusersPipelineLoader: if unexpected_missing: raise ValueError(f"Following weights were not initialized from checkpoint: {unexpected_missing}") - def _is_gguf_quantization(self, od_config: OmniDiffusionConfig) -> bool: - quant_config = od_config.quantization_config - if quant_config is None: - return False + def _get_gguf_model_from_config(self) -> str | None: + """Extract gguf_model from quant_config (handles both dict and object).""" + if self.quant_config is None: + return None + if isinstance(self.quant_config, dict): + return self.quant_config.get("gguf_model") + return getattr(self.quant_config, "gguf_model", None) - # New API: DiffusionGGUFConfig (QuantizationConfig subclass) + def _is_gguf_quantization(self) -> bool: + """Check whether or not this pipeline loader is pointing at a GGUF config.""" from vllm_omni.quantization.gguf_config import DiffusionGGUFConfig - if isinstance(quant_config, DiffusionGGUFConfig): - if quant_config.gguf_model is None: + maybe_gguf_model = self._get_gguf_model_from_config() + is_gguf = ( + isinstance(self.quant_config, DiffusionGGUFConfig) + or (isinstance(self.quant_config, dict) and self.quant_config.get("method") == "gguf") + or (hasattr(self.quant_config, "get_name") and self.quant_config.get_name() == "gguf") + ) + if is_gguf: + if not maybe_gguf_model: raise ValueError("GGUF quantization requires gguf_model") return True - # Dict-style config: {"method": "gguf", "gguf_model": "..."} - if isinstance(quant_config, dict): - if quant_config.get("method") == "gguf": - if not quant_config.get("gguf_model"): - raise ValueError("GGUF quantization requires gguf_model") - return True + if isinstance(self.quant_config, dict): return False - # Check by name for any config that reports as "gguf" - if hasattr(quant_config, "get_name") and quant_config.get_name() == "gguf": - gguf_model = getattr(quant_config, "gguf_model", None) - if gguf_model is None: - raise ValueError("GGUF quantization requires gguf_model") - return True - - # Fallback: object with gguf_model attribute but no get_name - if hasattr(quant_config, "gguf_model") and quant_config.gguf_model: - return True - - return False + return bool(maybe_gguf_model) def _is_transformer_source(self, source: "ComponentSource") -> bool: if source.subfolder == "transformer": @@ -571,25 +537,23 @@ class DiffusersPipelineLoader: self, source: "ComponentSource", model: nn.Module, - od_config: OmniDiffusionConfig, ) -> Generator[tuple[str, torch.Tensor], None, None]: - quant_config = od_config.quantization_config - gguf_model = getattr(quant_config, "gguf_model", None) + gguf_model = self._get_gguf_model_from_config() if gguf_model is None: raise ValueError("GGUF quantization requires quantization_config.gguf_model") - gguf_file = self._resolve_gguf_model_path(gguf_model, od_config.revision) - adapter = get_gguf_adapter(gguf_file, model, source, od_config) + gguf_file = self._resolve_gguf_model_path(gguf_model, self.od_config.revision) + adapter = get_gguf_adapter(gguf_file, model, source, self.od_config) weights_iter = adapter.weights_iterator() return ((source.prefix + name, tensor) for (name, tensor) in weights_iter) - def _load_weights_with_gguf(self, model: nn.Module, od_config: OmniDiffusionConfig) -> set[str]: + def _load_weights_with_gguf(self, model: nn.Module) -> set[str]: sources = self._get_weight_sources(model) loaded: set[str] = set() loadable_names: set[str] | None = None for source in sources: if self._is_transformer_source(source): - loaded |= model.load_weights(self._get_gguf_weights_iterator(source, model, od_config)) + loaded |= model.load_weights(self._get_gguf_weights_iterator(source, model)) # GGUF checkpoints can be transformer-only or partially quantized. # Only fall back to HF if this source still has missing loadable weights. @@ -614,9 +578,45 @@ class DiffusersPipelineLoader: raise ValueError(f"Following weights were not initialized from checkpoint: {weights_not_loaded}") return loaded + def _init_from_load_format( + self, + load_format: str, + target_device: torch.device, + custom_pipeline_name: str | type[nn.Module] | None = None, + is_hsdp: bool = False, + ) -> nn.Module: + """Initialize the model from a specified load format.""" + if load_format == "custom_pipeline": + # NOTE: Custom pipelines call HuggingFace `from_pretrained(...).to(device)` + # internally. If we construct them under `with target_device:` (CUDA), + # safetensors takes a direct-to-GPU fast path that calls `cudaMalloc` + # via the driver API and BYPASSES PyTorch's caching allocator. + # That makes those bytes invisible to CuMemAllocator, so `sleep()` + # cannot offload/unmap them and GPU memory stays pinned. + # + # Fix: build the custom pipeline on CPU first (no default device + # context), then explicitly move it to the target device. The + # subsequent `.to(target_device)` issues `torch.empty(..., device=cuda)` + # + `copy_`, which goes through the caching allocator and is fully + # tracked by CuMemAllocator. + model_cls = _resolve_custom_pipeline_cls(custom_pipeline_name) + with set_current_diffusion_config(self.od_config): + model = model_cls(od_config=self.od_config) + if not is_hsdp and target_device.type != "cpu": + model.to(target_device) + else: + device_ctx = target_device if not is_hsdp else contextlib.nullcontext() + with device_ctx: + if load_format == "default": + model = initialize_model(self.od_config) + elif load_format == "diffusers": + model = DiffusersAdapterPipeline(od_config=self.od_config, device=target_device) + else: + raise ValueError(f"Unknown load_format: {load_format}") + return model + def _load_model_with_hsdp( self, - od_config: OmniDiffusionConfig, target_device: torch.device, load_format: str = "default", custom_pipeline_name: str | type[nn.Module] | None = None, @@ -629,14 +629,11 @@ class DiffusersPipelineLoader: Approach: Load weights first using model's load_weights (handles QKV fusion etc.), then apply HSDP sharding to redistribute weights across GPUs. """ - from vllm_omni.diffusion.distributed.hsdp import apply_hsdp_to_model - - parallel_config = od_config.parallel_config hsdp_config = HSDPInferenceConfig( enabled=True, - hsdp_replicate_size=parallel_config.hsdp_replicate_size, - hsdp_shard_size=parallel_config.hsdp_shard_size, - param_dtype=od_config.dtype, + hsdp_replicate_size=self.parallel_config.hsdp_replicate_size, + hsdp_shard_size=self.parallel_config.hsdp_shard_size, + param_dtype=self.od_config.dtype, ) # Initialize model WITHOUT device context (weights start on CPU). @@ -644,14 +641,9 @@ class DiffusersPipelineLoader: # directly on GPU, HSDP needs weights on CPU first so they can be redistributed # across GPUs by apply_hsdp_to_model. The model's load_weights handles weight # mapping (QKV fusion, etc.). - if load_format == "default": - model = initialize_model(od_config) - elif load_format == "custom_pipeline": - model_cls = _resolve_custom_pipeline_cls(custom_pipeline_name) - from vllm_omni.diffusion.config import set_current_diffusion_config - - with set_current_diffusion_config(od_config): - model = model_cls(od_config=od_config) + if load_format == "diffusers": + raise ValueError("HSDP is not supported with the diffusers adapter load format") + model = self._init_from_load_format(load_format, target_device, custom_pipeline_name, is_hsdp=True) self.load_weights(model) # Collect all transformers to shard (some models have transformer_2 for MoE) diff --git a/vllm_omni/diffusion/worker/diffusion_model_runner.py b/vllm_omni/diffusion/worker/diffusion_model_runner.py index 636ac7f60..55e3dca1f 100644 --- a/vllm_omni/diffusion/worker/diffusion_model_runner.py +++ b/vllm_omni/diffusion/worker/diffusion_model_runner.py @@ -101,8 +101,8 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): def load_model( self, memory_pool_context_fn: callable | None = None, - load_format: str | None = None, - custom_pipeline_name: str | type | None = None, + load_format: str = "default", + custom_pipeline_name: str | None = None, ) -> None: """ Load the diffusion model, apply compilation and offloading. @@ -138,7 +138,6 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): with get_memory_context(): with DeviceMemoryProfiler() as m: self.pipeline = model_loader.load_model( - od_config=self.od_config, load_device=load_device, load_format=load_format, custom_pipeline_name=custom_pipeline_name, From 5de3b11822f50d1c5d5f10fd35208c9379554474 Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Tue, 9 Jun 2026 14:39:19 +0800 Subject: [PATCH 31/75] docs: update README and supported models for v0.22.0 release (#4233) Signed-off-by: Hongsheng Liu Signed-off-by: Yueqian Lin Co-authored-by: Yueqian Lin Co-authored-by: Claude Opus 4.7 --- README.md | 1 + docs/getting_started/installation/gpu/cuda.inc.md | 6 +++--- docs/getting_started/quickstart.md | 2 +- docs/models/supported_models.md | 8 ++++++++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7dfc9cb01..2ceecdf07 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone --- *Latest News* 🔥 +- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with veRL-Omni. - [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends. - [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments. - [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup! diff --git a/docs/getting_started/installation/gpu/cuda.inc.md b/docs/getting_started/installation/gpu/cuda.inc.md index 7f7607c82..a8f293afd 100644 --- a/docs/getting_started/installation/gpu/cuda.inc.md +++ b/docs/getting_started/installation/gpu/cuda.inc.md @@ -45,7 +45,7 @@ If you do not need to modify source code of vLLM, you can directly install the s uv pip install vllm==0.22.0 --torch-backend=auto ``` -The 0.22.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.22.0 release of vLLM ships CUDA 12.9-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 @@ -70,8 +70,8 @@ git checkout v0.22.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.22.0; the wheel filename has no `+cu130` suffix) -export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.22.0/vllm-0.22.0-cp38-abi3-manylinux_2_35_x86_64.whl +#For CUDA 12.9 (the default for v0.22.0; the wheel filename has no `+cu129` suffix) +export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.22.0/vllm-0.22.0-cp38-abi3-manylinux_2_28_x86_64.whl ``` Install vllm with command below (If you have no existing PyTorch). ```bash diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 4e3021f92..005c3f9e9 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -22,7 +22,7 @@ source .venv/bin/activate uv pip install vllm==0.22.0 --torch-backend=auto # On ROCm -uv pip install vllm==0.22.0+rocm721 --extra-index-url https://wheels.vllm.ai/rocm/0.22.0/rocm721 +uv pip install vllm==0.22.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.22.0/rocm722 git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 606e6e401..c47b6a503 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -50,6 +50,8 @@ th { | `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | | `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | +| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | | +| `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | | | `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | | `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `FluxKontextPipeline` | FLUX.1-Kontext-dev | `black-forest-labs/FLUX.1-Kontext-dev` | ✅︎ | ✅︎ | | | @@ -62,6 +64,10 @@ th { | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | | +| `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | | | +| `MossTTSDelayModel` | MOSS-TTS, MOSS-TTSD, MOSS-SoundEffect, MOSS-VoiceGenerator | `OpenMOSS-Team/MOSS-VoiceGenerator` | ✅︎ | | | | +| `MossTTSRealtime` | MOSS-TTS-Realtime | `OpenMOSS-Team/MOSS-TTS-Realtime` | ✅︎ | | | | +| `HiggsAudioV2ForConditionalGeneration` | Higgs-Audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✅︎ | | | | | `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ | | `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | | | `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | @@ -69,11 +75,13 @@ th { | `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | | | `DreamIDOmniPipeline` | DreamID-Omni | `XuGuo699/DreamID-Omni` | ✅︎ | ✅︎ | | | | `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only) | `SenseNova/SenseNova-U1-8B-MoT` | ✅︎ | | | | +| `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` | ✅︎ | ✅︎ | | | | `HunyuanVideo15ImageToVideoPipeline` | HunyuanVideo-1.5-I2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_i2v` | ✅︎ | ✅︎ | | | | `VoxtralTTSForConditionalGeneration` | Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | ✅︎ | ✅︎ | | | | `CovoAudioForConditionalGeneration` | Covo-Audio-Chat | `tencent/Covo-Audio-Chat` | ✅︎ | | | | |`DyninOmniForConditionalGeneration` | Dynin-Omni | `snu-aidas/Dynin-Omni` | ✅︎ | | | | +| `MiniCPMO45OmniForConditionalGeneration` | MiniCPM-o 4.5 | `openbmb/MiniCPM-o-4_5` | ✅︎ | | ✅︎ | | | `ErnieImagePipeline` | ERNIE-Image | `baidu/ERNIE-Image`, `baidu/ERNIE-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | |`HiDreamImagePipeline` | HiDream-I1-Full | `HiDream-ai/HiDream-I1-Full` | ✅︎ | ✅︎ | | | From 1b85df051027a18a413f7b7988dc76c89c184973 Mon Sep 17 00:00:00 2001 From: weizhoublue <45163302+weizhoublue@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:28:32 +0800 Subject: [PATCH 32/75] fix: Propagate Quantization Configuration to HunyuanVideo-1.5 I2V Transformer to Enable FP8 Layers (#4245) Signed-off-by: weizhoublue --- .../models/hunyuan_video/__init__.py | 2 + ..._hunyuan_video_quant_config_propagation.py | 160 ++++++++++++++++++ .../pipeline_hunyuan_video_1_5_i2v.py | 4 +- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tests/diffusion/models/hunyuan_video/__init__.py create mode 100644 tests/diffusion/models/hunyuan_video/test_hunyuan_video_quant_config_propagation.py diff --git a/tests/diffusion/models/hunyuan_video/__init__.py b/tests/diffusion/models/hunyuan_video/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/tests/diffusion/models/hunyuan_video/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/tests/diffusion/models/hunyuan_video/test_hunyuan_video_quant_config_propagation.py b/tests/diffusion/models/hunyuan_video/test_hunyuan_video_quant_config_propagation.py new file mode 100644 index 000000000..524a9b93b --- /dev/null +++ b/tests/diffusion/models/hunyuan_video/test_hunyuan_video_quant_config_propagation.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for HunyuanVideo-1.5 quant_config propagation through transformer creation. + +Tests cover: +- HunyuanVideo15Pipeline passes quant_config to HunyuanVideo15Transformer3DModel +- HunyuanVideo15I2VPipeline passes quant_config to HunyuanVideo15Transformer3DModel +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import torch + +import vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5 as t2v_module +import vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v as i2v_module + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +class TestHunyuanVideoQuantConfigPropagation: + """Verify quant_config is propagated to the transformer model in HunyuanVideo-1.5 pipelines.""" + + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.get_local_device", + return_value="cpu", + ) + @patch("vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.prefetch_subfolders") + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.from_pretrained_with_prefetch", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.Qwen2Tokenizer.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.ByT5Tokenizer.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.AutoConfig.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.T5EncoderModel", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.FlowMatchEulerDiscreteScheduler.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5.get_transformer_config_kwargs", + return_value={}, + ) + def test_t2v_quant_config_passed_through( + self, + mock_get_kwargs, + mock_scheduler, + mock_t5_encoder, + mock_auto_config, + mock_byt5_tok, + mock_qwen2_tok, + mock_from_pretrained, + mock_prefetch, + mock_get_device, + ): + captured_kwargs = {} + + class FakeTransformer: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + with patch.object(t2v_module, "HunyuanVideo15Transformer3DModel", FakeTransformer): + fake_qc = MagicMock() + od_config = SimpleNamespace( + model="fake-t2v-model", + dtype=torch.bfloat16, + flow_shift=None, + tf_model_config=SimpleNamespace(), + quantization_config=fake_qc, + enable_diffusion_pipeline_profiler=False, + ) + + t2v_module.HunyuanVideo15Pipeline(od_config=od_config) + + assert captured_kwargs.get("quant_config") is fake_qc + + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.get_local_device", + return_value="cpu", + ) + @patch("vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.prefetch_subfolders") + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.from_pretrained_with_prefetch", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.Qwen2Tokenizer.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.ByT5Tokenizer.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.AutoConfig.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.T5EncoderModel", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.SiglipImageProcessor.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.FlowMatchEulerDiscreteScheduler.from_pretrained", + return_value=MagicMock(), + ) + @patch( + "vllm_omni.diffusion.models.hunyuan_video.pipeline_hunyuan_video_1_5_i2v.get_transformer_config_kwargs", + return_value={}, + ) + def test_i2v_quant_config_passed_through( + self, + mock_get_kwargs, + mock_scheduler, + mock_siglip_processor, + mock_t5_encoder, + mock_auto_config, + mock_byt5_tok, + mock_qwen2_tok, + mock_from_pretrained, + mock_prefetch, + mock_get_device, + ): + captured_kwargs = {} + + class FakeTransformer: + def __init__(self, **kwargs): + captured_kwargs.update(kwargs) + + with patch.object(i2v_module, "HunyuanVideo15Transformer3DModel", FakeTransformer): + fake_qc = MagicMock() + od_config = SimpleNamespace( + model="fake-i2v-model", + dtype=torch.bfloat16, + flow_shift=None, + tf_model_config=SimpleNamespace(), + quantization_config=fake_qc, + enable_diffusion_pipeline_profiler=False, + ) + + i2v_module.HunyuanVideo15I2VPipeline(od_config=od_config) + + assert captured_kwargs.get("quant_config") is fake_qc diff --git a/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5_i2v.py b/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5_i2v.py index 9eb296150..583ef87ed 100644 --- a/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5_i2v.py +++ b/vllm_omni/diffusion/models/hunyuan_video/pipeline_hunyuan_video_1_5_i2v.py @@ -186,7 +186,9 @@ class HunyuanVideo15I2VPipeline( self.scheduler._shift = od_config.flow_shift transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, HunyuanVideo15Transformer3DModel) - self.transformer = HunyuanVideo15Transformer3DModel(od_config=od_config, **transformer_kwargs) + self.transformer = HunyuanVideo15Transformer3DModel( + od_config=od_config, quant_config=od_config.quantization_config, **transformer_kwargs + ) self.use_meanflow = getattr(od_config.tf_model_config, "use_meanflow", False) From cb1c5a0c39c535f6ca3298ac57757db5293d7cdd Mon Sep 17 00:00:00 2001 From: yangjianjuan <66548561+yangjianjuan@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:31:57 +0800 Subject: [PATCH 33/75] [Perf] lance perf optimize (t2i & i2i) (#4214) Signed-off-by: yangjianjuan <510818155@qq.com> --- .../diffusion/models/lance/pipeline_lance.py | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/vllm_omni/diffusion/models/lance/pipeline_lance.py b/vllm_omni/diffusion/models/lance/pipeline_lance.py index 5639e9261..6f7e9b95e 100644 --- a/vllm_omni/diffusion/models/lance/pipeline_lance.py +++ b/vllm_omni/diffusion/models/lance/pipeline_lance.py @@ -793,17 +793,9 @@ class LancePipeline(BagelPipeline): curr_rope=cfg_text_context["ropes"], video_shapes=[video_shape], ) - cfg_img_lat = self.bagel.prepare_video_latent_cfg( - curr_kvlens=gen_context["kv_lens"], - curr_rope=gen_context["ropes"], - video_shapes=[video_shape], - ) for k, v in cfg_text_lat.items(): if torch.is_tensor(v): cfg_text_lat[k] = v.to(self.device) - for k, v in cfg_img_lat.items(): - if torch.is_tensor(v): - cfg_img_lat[k] = v.to(self.device) self._regen_init_noise_on_device(gen_input_lat, req.sampling_params.seed) @@ -822,7 +814,7 @@ class LancePipeline(BagelPipeline): latents, *_ = self.bagel.generate_image( past_key_values=gen_context["past_key_values"], cfg_text_past_key_values=cfg_text_context["past_key_values"], - cfg_img_past_key_values=gen_context["past_key_values"], # no img CFG branch + cfg_img_past_key_values=None, # no img CFG branch num_timesteps=num_timesteps, timestep_shift=timestep_shift, cfg_text_scale=cfg_text_scale, @@ -835,7 +827,7 @@ class LancePipeline(BagelPipeline): # ``cfg_text_packed_query_indexes`` / ``cfg_text_key_values_lens`` # / ``cfg_text_packed_key_value_indexes`` removed post-main-merge # (derived from ``cfg_text_past_key_values``). - cfg_img_packed_position_ids=cfg_img_lat["cfg_packed_position_ids"], + cfg_img_packed_position_ids=None, # ``cfg_img_*`` index/lens kwargs removed — same as above. ) @@ -1038,11 +1030,10 @@ class LancePipeline(BagelPipeline): if torch.is_tensor(v): cfg_text_lat[k] = v.to(self.device) - # cfg_img branch: keep off (cfg_img_scale=1.0 matches upstream - # ``cfg_vit_scale=1.0``). generate_image needs the cfg_img_* args - # but skips the branch when scale<=1.0; just point them at the - # cond's lat metadata so shapes/types are fine. - cfg_img_lat = cfg_text_lat + # cfg_img branch is off by default (cfg_img_scale=1.0 matches + # upstream ``cfg_vit_scale=1.0``). Avoid passing synthetic metadata + # in that path; Bagel.generate_image only reads it when scale > 1.0. + cfg_img_lat = cfg_text_lat if cfg_img_scale > 1.0 else None self._regen_init_noise_on_device(gen_input_lat, req.sampling_params.seed) @@ -1071,7 +1062,9 @@ class LancePipeline(BagelPipeline): # ``cfg_text_packed_query_indexes`` / ``cfg_text_key_values_lens`` # / ``cfg_text_packed_key_value_indexes`` removed post-main-merge # (derived from ``cfg_text_past_key_values``). - cfg_img_packed_position_ids=cfg_img_lat["cfg_packed_position_ids"], + cfg_img_packed_position_ids=( + cfg_img_lat["cfg_packed_position_ids"] if cfg_img_lat is not None else None + ), # ``cfg_img_*`` index/lens kwargs removed — same as above. ) @@ -1269,7 +1262,7 @@ class LancePipeline(BagelPipeline): for k, v in cfg_text_lat.items(): if torch.is_tensor(v): cfg_text_lat[k] = v.to(self.device) - cfg_img_lat = cfg_text_lat + cfg_img_lat = cfg_text_lat if cfg_img_scale > 1.0 else None self._regen_init_noise_on_device(gen_input_lat, req.sampling_params.seed) @@ -1358,7 +1351,9 @@ class LancePipeline(BagelPipeline): # ``cfg_text_packed_query_indexes`` / ``cfg_text_key_values_lens`` # / ``cfg_text_packed_key_value_indexes`` removed post-main-merge # (derived from ``cfg_text_past_key_values``). - cfg_img_packed_position_ids=cfg_img_lat["cfg_packed_position_ids"], + cfg_img_packed_position_ids=( + cfg_img_lat["cfg_packed_position_ids"] if cfg_img_lat is not None else None + ), # ``cfg_img_*`` index/lens kwargs removed — same as above. frame_condition_token_indexes=frame_condition_token_indexes, ) @@ -1587,9 +1582,9 @@ class LancePipeline(BagelPipeline): if torch.is_tensor(v): cfg_text_lat[k] = v.to(self.device) - # cfg_img branch is off (cfg_img_scale=1.0); generate_image needs the - # arg surface but skips the branch when scale<=1.0. - cfg_img_lat = cfg_text_lat + # cfg_img branch is off by default; pass metadata only when an + # explicit cfg_img_scale enables the branch. + cfg_img_lat = cfg_text_lat if cfg_img_scale > 1.0 else None self._regen_init_noise_on_device(gen_input_lat, req.sampling_params.seed) @@ -1618,7 +1613,9 @@ class LancePipeline(BagelPipeline): # ``cfg_text_packed_query_indexes`` / ``cfg_text_key_values_lens`` # / ``cfg_text_packed_key_value_indexes`` removed post-main-merge # (derived from ``cfg_text_past_key_values``). - cfg_img_packed_position_ids=cfg_img_lat["cfg_packed_position_ids"], + cfg_img_packed_position_ids=( + cfg_img_lat["cfg_packed_position_ids"] if cfg_img_lat is not None else None + ), # ``cfg_img_*`` index/lens kwargs removed — same as above. ) From 02eb7a4ecb5cd602d1e3dc296b0ee03928e9218b Mon Sep 17 00:00:00 2001 From: Mu GuanLin <39453182+mglyn@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:09:39 +0800 Subject: [PATCH 34/75] [Bugfix] Register LTX RMSNorm identity weight as buffer (#4278) Signed-off-by: mglyn <1203789601@qq.com> --- .../models/ltx2/test_ltx2_transformer.py | 22 +++++++++++++++++++ .../diffusion/models/ltx2/ltx2_transformer.py | 12 +++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/diffusion/models/ltx2/test_ltx2_transformer.py diff --git a/tests/diffusion/models/ltx2/test_ltx2_transformer.py b/tests/diffusion/models/ltx2/test_ltx2_transformer.py new file mode 100644 index 000000000..e71253297 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_transformer.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from torch import nn + +from vllm_omni.diffusion.models.ltx2.ltx2_transformer import _make_rms_norm + + +def test_ltx_rms_norm_no_affine_identity_weight_is_non_persistent_buffer(): + norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=False) + + assert "weight" not in dict(norm.named_parameters()) + assert "weight" in dict(norm.named_buffers()) + assert "weight" not in norm.state_dict() + + +def test_ltx_rms_norm_affine_weight_remains_parameter(): + norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=True) + + assert isinstance(dict(norm.named_parameters())["weight"], nn.Parameter) + assert "weight" not in dict(norm.named_buffers()) + assert "weight" in norm.state_dict() diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py index a1d72cf66..4e5659c54 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py @@ -62,7 +62,17 @@ def _make_rms_norm(hidden_size: int, *, eps: float, elementwise_affine: bool) -> kwargs["has_weight"] = elementwise_affine elif not elementwise_affine: raise TypeError("RMSNorm backend does not support disabling affine weights.") - return RMSNorm(hidden_size, **kwargs) + norm = RMSNorm(hidden_size, **kwargs) + weight = getattr(norm, "weight", None) + if ( + not elementwise_affine + and isinstance(weight, torch.Tensor) + and not isinstance(weight, nn.Parameter) + and "weight" not in dict(norm.named_buffers()) + ): + delattr(norm, "weight") + norm.register_buffer("weight", weight, persistent=False) + return norm def apply_interleaved_rotary_emb(x: torch.Tensor, freqs: tuple[torch.Tensor, torch.Tensor]) -> torch.Tensor: From 6ddaf6188d1455ce306e88113610eb53dedb9473 Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Tue, 9 Jun 2026 18:08:09 +0800 Subject: [PATCH 35/75] Fix README.md typo (#4292) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ceecdf07..9df4508c9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone --- *Latest News* 🔥 -- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with veRL-Omni. +- [2026/06] We released [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) - an **omnimodal world-model** release aligned with vLLM 0.22, featuring [Nvidia Cosmos3](recipes/cosmos3/Cosmos3-Nano.md)/DreamZero world model support, expanded quantization coverage across Blackwell/NPU/XPU, TTS production improvements, new models including MiniCPM-o 4.5, MOSS-TTS, and Lance, plus RL integration with [VeRL-Omni](https://github.com/verl-project/verl-omni). - [2026/05] We released [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0) - refreshes the serving/runtime stack for large-scale omni workloads, and improves diffusion model performance, quantization, and hardware readiness across CUDA, ROCm, MUSA, NPU, and XPU backends. - [2026/03] We released [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0) - strengthens the core runtime through a large entrypoint refactor and scheduler/runtime cleanups, expands unified quantization and diffusion execution, broadens multimodal model coverage, and improves production readiness across audio, omni, image, video, RL, and multi-platform deployments. - [2026/03] Check out our first public [project deepdive](https://youtu.be/sgwNfsNnR9I) at the vLLM Hong Kong Meetup! From f174ee54b06654136dbf06146598b87d4253fc87 Mon Sep 17 00:00:00 2001 From: "Chendi.Xue" Date: Tue, 9 Jun 2026 06:28:18 -0500 Subject: [PATCH 36/75] [XPU] Add sage_attn backend (#3785) Signed-off-by: Chendi Xue --- requirements/xpu.txt | 1 + .../diffusion/attention/backends/sage_attn.py | 91 +++++++++++++++++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/requirements/xpu.txt b/requirements/xpu.txt index c7732b1e1..e59520dec 100644 --- a/requirements/xpu.txt +++ b/requirements/xpu.txt @@ -1,2 +1,3 @@ -r common.txt onnxruntime>=1.23.2 +auto-round-lib diff --git a/vllm_omni/diffusion/attention/backends/sage_attn.py b/vllm_omni/diffusion/attention/backends/sage_attn.py index 080a241c1..37eab409a 100644 --- a/vllm_omni/diffusion/attention/backends/sage_attn.py +++ b/vllm_omni/diffusion/attention/backends/sage_attn.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import os + import torch from vllm.logger import init_logger @@ -9,17 +11,43 @@ from vllm_omni.diffusion.attention.backends.abstract import ( AttentionImpl, AttentionMetadata, ) +from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) -try: - from sageattention import sageattn -except ImportError: - logger.warning( - "SageAttentionBackend is not available. You may install sage-attention" - " by pip install git+https://github.com/thu-ml/SageAttention.git" - ) - raise ImportError +_SAGE_ATTN_TENSOR_LAYOUT = os.environ.get("SAGE_ATTN_TENSOR_LAYOUT", "NHD").upper() +assert _SAGE_ATTN_TENSOR_LAYOUT in ("NHD", "HND"), ( + f"SAGE_ATTN_TENSOR_LAYOUT must be 'NHD' or 'HND', got '{_SAGE_ATTN_TENSOR_LAYOUT}'" +) + +if current_omni_platform.is_xpu(): + try: + import inspect + + from auto_round_kernel import ARK + + _ark = ARK() + xpu_sageattn = _ark.sagev1 + _sagev1_params = inspect.signature(xpu_sageattn).parameters + _sagev1_has_tensor_layout = "tensor_layout" in _sagev1_params + _sagev1_scale_param = "sm_scale" if "sm_scale" in _sagev1_params else "scale" + except ImportError: + logger.warning( + "XPU SageAttention (auto_round_kernel.ARK.sagev1) is not available. " + "Install auto-round-lib for XPU sage attention support." + ) + xpu_sageattn = None + _sagev1_has_tensor_layout = False + _sagev1_scale_param = "scale" +else: + try: + from sageattention import sageattn + except ImportError: + logger.warning( + "SageAttentionBackend is not available. You may install sage-attention" + " by pip install git+https://github.com/thu-ml/SageAttention.git" + ) + raise ImportError # TODO add sage3 attention backend @@ -73,3 +101,50 @@ class SageAttentionImpl(AttentionImpl): sm_scale=self.softmax_scale, ) return output + + def forward_xpu( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attn_metadata: AttentionMetadata = None, + ) -> torch.Tensor: + if xpu_sageattn is None: + raise ImportError("XPU SageAttention requires auto-round-lib. Install with: pip install auto-round-lib") + orig_dtype = query.dtype + q = query.to(torch.float16) if orig_dtype != torch.float16 else query + k = key.to(torch.float16) if orig_dtype != torch.float16 else key + v = value.to(torch.float16) if orig_dtype != torch.float16 else value + + if _sagev1_has_tensor_layout: + if _SAGE_ATTN_TENSOR_LAYOUT == "HND": + q = q.transpose(1, 2).contiguous() + k = k.transpose(1, 2).contiguous() + v = v.transpose(1, 2).contiguous() + output = xpu_sageattn( + q, + k, + v, + tensor_layout=_SAGE_ATTN_TENSOR_LAYOUT, + is_causal=self.causal, + **{_sagev1_scale_param: self.softmax_scale}, + ) + if _SAGE_ATTN_TENSOR_LAYOUT == "HND": + output = output.transpose(1, 2).contiguous() + else: + # No tensor_layout support: kernel expects HND [B, H, S, D] + q = q.transpose(1, 2).contiguous() + k = k.transpose(1, 2).contiguous() + v = v.transpose(1, 2).contiguous() + output = xpu_sageattn( + q, + k, + v, + is_causal=self.causal, + **{_sagev1_scale_param: self.softmax_scale}, + ) + output = output.transpose(1, 2).contiguous() + + if orig_dtype != torch.float16: + output = output.to(orig_dtype) + return output From bf03413c3651bc01dc1f690f951cf98eee61dd45 Mon Sep 17 00:00:00 2001 From: Mu GuanLin <39453182+mglyn@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:14:16 +0800 Subject: [PATCH 37/75] [Feat] Add vae-decode-parallel for LTX-2.3 (#4277) Signed-off-by: mglyn <1203789601@qq.com> --- docs/user_guide/diffusion_features.md | 2 +- .../models/ltx2/test_ltx2_3_pipeline.py | 254 ++++++++++++++++++ .../autoencoders/autoencoder_kl_ltx2.py | 210 +++++++++++++++ .../diffusion/models/ltx2/pipeline_ltx2_3.py | 26 +- 4 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_ltx2.py diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index d4b0c9177..234446c01 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -148,7 +148,7 @@ The following tables show which models support each feature: | **Wan2.2-S2V** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ | | **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ | | **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py index 9943d092b..bf6a75636 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py @@ -97,6 +97,260 @@ class TestPipelineIndependence: assert modules.resident_names == ["vocoder"] assert len(modules.vaes) == 2 + def test_ltx23_pipeline_has_diffusion_pipeline_profiler_mixin(self): + """LTX23Pipeline must support lightweight stage timing.""" + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin + + assert issubclass(LTX23Pipeline, DiffusionPipelineProfilerMixin) + + +class TestLTX23VaeDecodeParallel: + """Test LTX-2.3 video VAE tiled parallel helpers without loading weights.""" + + def test_ltx23_video_vae_is_distributed_tile_only_class(self): + from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import ( + DistributedAutoencoderKLLTX2Video, + ) + from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import DistributedVaeMixin + + assert issubclass(DistributedAutoencoderKLLTX2Video, DistributedVaeMixin) + assert not hasattr(DistributedAutoencoderKLLTX2Video, "patch_split") + + def test_ltx23_video_vae_tile_split_uses_native_ltx23_tile_geometry(self): + from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import ( + DistributedAutoencoderKLLTX2Video, + ) + + vae = SimpleNamespace( + spatial_compression_ratio=32, + tile_sample_min_height=512, + tile_sample_min_width=512, + tile_sample_stride_height=448, + tile_sample_stride_width=448, + temporal_compression_ratio=8, + dtype=torch.float32, + ) + + z = torch.zeros(1, 2, 5, 16, 24) + tasks, grid_spec = DistributedAutoencoderKLLTX2Video.tile_split(vae, z) + + assert grid_spec.grid_shape == (2, 2) + assert grid_spec.split_dims == (3, 4) + assert grid_spec.tile_spec["sample_height"] == 512 + assert grid_spec.tile_spec["sample_width"] == 768 + assert grid_spec.tile_spec["blend_height"] == 64 + assert grid_spec.tile_spec["blend_width"] == 64 + assert grid_spec.tile_spec["max_tile_output_shape"] == (1, 3, 33, 512, 512) + assert grid_spec.tile_spec["tile_output_shapes"] == { + 0: (1, 3, 33, 512, 512), + 1: (1, 3, 33, 512, 320), + 2: (1, 3, 33, 64, 512), + 3: (1, 3, 33, 64, 320), + } + assert [task.grid_coord for task in tasks] == [(0, 0), (0, 1), (1, 0), (1, 1)] + assert [tuple(task.tensor.shape) for task in tasks] == [ + (1, 2, 5, 16, 16), + (1, 2, 5, 16, 10), + (1, 2, 5, 2, 16), + (1, 2, 5, 2, 10), + ] + assert [task.workload for task in tasks] == [5 * 16 * 16, 5 * 16 * 10, 5 * 2 * 16, 5 * 2 * 10] + + def test_ltx23_video_vae_tile_merge_blends_and_crops_like_tiled_decode(self): + from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import ( + DistributedAutoencoderKLLTX2Video, + ) + from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import GridSpec + + class FakeVae: + def __init__(self): + self.blend_calls = [] + + def clear_cache(self): + pass + + def blend_v(self, _previous, current, blend_height): + self.blend_calls.append(("v", blend_height)) + return current + + def blend_h(self, _previous, current, blend_width): + self.blend_calls.append(("h", blend_width)) + return current + + fake_vae = FakeVae() + grid_spec = GridSpec( + split_dims=(3, 4), + grid_shape=(2, 2), + tile_spec={ + "sample_height": 10, + "sample_width": 10, + "blend_height": 1, + "blend_width": 2, + "tile_sample_stride_height": 5, + "tile_sample_stride_width": 5, + }, + ) + tiles = { + (0, 0): torch.full((1, 3, 2, 6, 6), 1.0), + (0, 1): torch.full((1, 3, 2, 6, 6), 2.0), + (1, 0): torch.full((1, 3, 2, 6, 6), 3.0), + (1, 1): torch.full((1, 3, 2, 6, 6), 4.0), + } + + merged = DistributedAutoencoderKLLTX2Video.tile_merge(fake_vae, tiles, grid_spec) + + assert merged.shape == (1, 3, 2, 10, 10) + assert fake_vae.blend_calls == [("h", 2), ("v", 1), ("v", 1), ("h", 2)] + torch.testing.assert_close(merged[:, :, :, :5, :5], torch.ones(1, 3, 2, 5, 5)) + torch.testing.assert_close(merged[:, :, :, :5, 5:], torch.full((1, 3, 2, 5, 5), 2.0)) + torch.testing.assert_close(merged[:, :, :, 5:, :5], torch.full((1, 3, 2, 5, 5), 3.0)) + torch.testing.assert_close(merged[:, :, :, 5:, 5:], torch.full((1, 3, 2, 5, 5), 4.0)) + + def test_ltx23_video_vae_tiled_decode_dispatches_to_tile_operator(self): + from vllm_omni.diffusion.distributed.autoencoders import autoencoder_kl_ltx2 + + z = torch.zeros(1, 2, 1, 16, 24) + expected = torch.ones(1, 3, 1, 512, 768) + seen = {} + + class FakeExecutor: + def execute(self, tensor, operator, broadcast_result=True): + seen["tensor"] = tensor + seen["operator"] = operator + seen["broadcast_result"] = broadcast_result + return expected + + vae = SimpleNamespace(distributed_executor=FakeExecutor(), is_distributed_enabled=lambda: True) + vae.tile_split = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_split.__get__(vae) + vae.tile_exec = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_exec.__get__(vae) + vae.tile_merge = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tile_merge.__get__(vae) + + output = autoencoder_kl_ltx2.DistributedAutoencoderKLLTX2Video.tiled_decode( + vae, + z, + temb=torch.tensor(0.5), + return_dict=False, + ) + + assert len(output) == 1 + assert output[0] is expected + assert seen["tensor"] is z + assert seen["broadcast_result"] is False + assert seen["operator"].split.__name__ == "tile_split" + assert seen["operator"].merge.__name__ == "tile_merge" + + def test_ltx23_vae_executor_gathers_known_tile_shapes_and_returns_empty_on_non_rank0(self): + from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import LTX2VaeExecutor + from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import ( + DistributedOperator, + GridSpec, + TileTask, + ) + + z = torch.zeros(1, 1, 1, 1, 1) + tile_output_shapes = { + 0: (1, 1, 1, 2, 2), + 1: (1, 1, 1, 2, 1), + 2: (1, 1, 1, 1, 2), + 3: (1, 1, 1, 1, 1), + } + tasks = [ + TileTask(0, (0, 0), z, workload=4), + TileTask(1, (0, 1), z, workload=2), + TileTask(2, (1, 0), z, workload=2), + TileTask(3, (1, 1), z, workload=1), + ] + grid_spec = GridSpec( + split_dims=(3, 4), + grid_shape=(2, 2), + tile_spec={ + "max_tile_output_shape": (1, 1, 1, 2, 2), + "tile_output_shapes": tile_output_shapes, + }, + output_dtype=torch.float32, + ) + seen = {} + + def exec_tile(task): + return torch.full(tile_output_shapes[task.tile_id], float(task.tile_id + 1)) + + def merge_tiles(coord_tensor_map, passed_grid_spec): + seen["merged_shapes"] = {coord: tuple(tile.shape) for coord, tile in coord_tensor_map.items()} + assert passed_grid_spec is grid_spec + return torch.stack( + [ + coord_tensor_map[(0, 0)].flatten()[0], + coord_tensor_map[(0, 1)].flatten()[0], + coord_tensor_map[(1, 0)].flatten()[0], + coord_tensor_map[(1, 1)].flatten()[0], + ] + ) + + operator = DistributedOperator(split=lambda _z: (tasks, grid_spec), exec=exec_tile, merge=merge_tiles) + + rank0_executor = object.__new__(LTX2VaeExecutor) + rank0_executor.parallel_size = 2 + rank0_executor.world_size = 2 + rank0_executor.rank = 0 + + def gather_rank0(local_tile_tensor): + assigned = rank0_executor._balance_tasks(tasks, 2) + rank1_results = [(task.tile_id, exec_tile(task)) for task in assigned[1]] + rank1_tile_tensor = rank0_executor._pack_local_tiles_without_meta( + rank1_results, + list(local_tile_tensor.shape), + z.device, + torch.float32, + ) + seen["rank0_gather_shape"] = tuple(local_tile_tensor.shape) + return [local_tile_tensor, rank1_tile_tensor] + + def fail_final_sync(*_args, **_kwargs): + raise AssertionError("broadcast_result=False should not sync the final result") + + rank0_executor.gather_tensors = gather_rank0 + rank0_executor._sync_final_result = fail_final_sync + + rank0_result = rank0_executor.execute(z, operator, broadcast_result=False) + + torch.testing.assert_close(rank0_result, torch.tensor([1.0, 2.0, 3.0, 4.0])) + assert seen["rank0_gather_shape"] == (2, 1, 1, 1, 2, 2) + assert seen["merged_shapes"] == { + (0, 0): (1, 1, 1, 2, 2), + (0, 1): (1, 1, 1, 2, 1), + (1, 0): (1, 1, 1, 1, 2), + (1, 1): (1, 1, 1, 1, 1), + } + + non_rank0_executor = object.__new__(LTX2VaeExecutor) + non_rank0_executor.parallel_size = 2 + non_rank0_executor.world_size = 2 + non_rank0_executor.rank = 1 + + def gather_rank1(local_tile_tensor): + seen["rank1_gather_shape"] = tuple(local_tile_tensor.shape) + return None + + def fail_non_rank0_merge(*_args, **_kwargs): + raise AssertionError("non-rank0 should not merge gathered tiles") + + non_rank0_executor.gather_tensors = gather_rank1 + non_rank0_executor._sync_final_result = fail_final_sync + + empty_result = non_rank0_executor.execute( + z, + DistributedOperator( + split=lambda _z: (tasks, grid_spec), + exec=exec_tile, + merge=fail_non_rank0_merge, + ), + broadcast_result=False, + ) + + assert tuple(empty_result.shape) == (0,) + assert seen["rank1_gather_shape"] == (2, 1, 1, 1, 2, 2) + class TestCFGParallelHelpers: """Test LTX-2.3 CFG helper math without loading model weights.""" diff --git a/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_ltx2.py b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_ltx2.py new file mode 100644 index 000000000..0e7410808 --- /dev/null +++ b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_ltx2.py @@ -0,0 +1,210 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import Any + +import torch +from diffusers import AutoencoderKLLTX2Video +from diffusers.models.autoencoders.vae import DecoderOutput +from vllm.logger import init_logger + +from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import ( + DistributedOperator, + DistributedVaeExecutor, + DistributedVaeMixin, + GridSpec, + TileTask, +) + +logger = init_logger(__name__) + + +class LTX2VaeExecutor(DistributedVaeExecutor): + def _pack_local_tiles_without_meta(self, local_results, global_padding_shape, device, dtype): + tile_tensor = torch.zeros(global_padding_shape, device=device, dtype=dtype) + for idx, (_, t_tensor) in enumerate(local_results): + slices = tuple(slice(0, s) for s in t_tensor.shape) + tile_tensor[idx][slices] = t_tensor + return tile_tensor + + def _unpack_tiles_with_known_metadata(self, tile_gather, assigned, tile_output_shapes): + coord_tensor_map = {} + for r, rank_tasks in enumerate(assigned): + tiles_src = tile_gather[r] + for idx, task in enumerate(rank_tasks): + output_shape = tile_output_shapes[task.tile_id] + slices = tuple(slice(0, dim) for dim in output_shape) + coord_tensor_map[task.grid_coord] = tiles_src[idx][slices] + + return coord_tensor_map + + def execute(self, z: torch.Tensor, operator: DistributedOperator, broadcast_result: bool = True): + pp_size = min(self.parallel_size, self.world_size) + + tiletask_list, grid_spec = operator.split(z) + max_tile_output_shape = grid_spec.tile_spec.get("max_tile_output_shape") + tile_output_shapes = grid_spec.tile_spec.get("tile_output_shapes") + if max_tile_output_shape is None or tile_output_shapes is None: + return super().execute(z, operator, broadcast_result=broadcast_result) + + assigned = self._balance_tasks(tiletask_list, pp_size) + local_tasks = assigned[self.rank] if self.rank < pp_size else [] + local_results = [(t.tile_id, operator.exec(t)) for t in local_tasks] + + output_dtype = grid_spec.output_dtype if grid_spec.output_dtype is not None else z.dtype + max_local_tile_count = max(len(tasks) for tasks in assigned) + global_padding_shape = [max_local_tile_count, *max_tile_output_shape] + local_tile_tensor = self._pack_local_tiles_without_meta( + local_results, + global_padding_shape, + z.device, + output_dtype, + ) + tile_gather = self.gather_tensors(local_tile_tensor) + + if self.rank != 0: + result = torch.empty(0, device=z.device) + else: + coord_tensor_map = self._unpack_tiles_with_known_metadata(tile_gather, assigned, tile_output_shapes) + result = operator.merge(coord_tensor_map, grid_spec) + + if broadcast_result: + result = self._sync_final_result(result, z.ndim, z.device, output_dtype) + return result + + +class DistributedAutoencoderKLLTX2Video(AutoencoderKLLTX2Video, DistributedVaeMixin): + @classmethod + def from_pretrained(cls, *args: Any, **kwargs: Any): + model = super().from_pretrained(*args, **kwargs) + model.init_distributed() + return model + + def init_distributed(self): + self.distributed_executor = LTX2VaeExecutor() + + def tile_split(self, z: torch.Tensor) -> tuple[list[TileTask], GridSpec]: + _, _, num_frames, height, width = z.shape + sample_height = height * self.spatial_compression_ratio + sample_width = width * self.spatial_compression_ratio + + 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 + tile_latent_stride_height = self.tile_sample_stride_height // self.spatial_compression_ratio + tile_latent_stride_width = self.tile_sample_stride_width // self.spatial_compression_ratio + sample_num_frames = (num_frames - 1) * self.temporal_compression_ratio + 1 + output_channels = int(getattr(getattr(self, "config", None), "out_channels", 3)) + + tiletask_list = [] + tile_output_shapes = {} + for i in range(0, height, tile_latent_stride_height): + for j in range(0, width, tile_latent_stride_width): + tile = z[:, :, :num_frames, i : i + tile_latent_min_height, j : j + tile_latent_min_width] + tile_id = len(tiletask_list) + tile_output_shapes[tile_id] = ( + z.shape[0], + output_channels, + sample_num_frames, + tile.shape[3] * self.spatial_compression_ratio, + tile.shape[4] * self.spatial_compression_ratio, + ) + tiletask_list.append( + TileTask( + tile_id, + (i // tile_latent_stride_height, j // tile_latent_stride_width), + tile, + workload=tile.shape[2] * tile.shape[3] * tile.shape[4], + ) + ) + + tile_spec = { + "sample_height": sample_height, + "sample_width": sample_width, + "blend_height": self.tile_sample_min_height - self.tile_sample_stride_height, + "blend_width": self.tile_sample_min_width - self.tile_sample_stride_width, + "tile_sample_stride_height": self.tile_sample_stride_height, + "tile_sample_stride_width": self.tile_sample_stride_width, + "max_tile_output_shape": ( + z.shape[0], + output_channels, + sample_num_frames, + self.tile_sample_min_height, + self.tile_sample_min_width, + ), + "tile_output_shapes": tile_output_shapes, + } + grid_spec = GridSpec( + split_dims=(3, 4), + grid_shape=(tiletask_list[-1].grid_coord[0] + 1, tiletask_list[-1].grid_coord[1] + 1), + tile_spec=tile_spec, + output_dtype=self.dtype, + ) + return tiletask_list, grid_spec + + def tile_exec( + self, + task: TileTask, + temb: torch.Tensor | None = None, + causal: bool | None = None, + ) -> torch.Tensor: + if hasattr(self, "clear_cache"): + self.clear_cache() + return self.decoder(task.tensor, temb, causal=causal) + + def tile_merge(self, coord_tensor_map: dict[tuple[int, ...], torch.Tensor], grid_spec: GridSpec) -> torch.Tensor: + grid_h, grid_w = grid_spec.grid_shape + result_rows = [] + + if hasattr(self, "clear_cache"): + self.clear_cache() + + for i in range(grid_h): + result_row = [] + for j in range(grid_w): + tile = coord_tensor_map[(i, j)] + if i > 0: + tile = self.blend_v(coord_tensor_map[(i - 1, j)], tile, grid_spec.tile_spec["blend_height"]) + if j > 0: + tile = self.blend_h(coord_tensor_map[(i, j - 1)], tile, grid_spec.tile_spec["blend_width"]) + result_row.append( + tile[ + :, + :, + :, + : grid_spec.tile_spec["tile_sample_stride_height"], + : grid_spec.tile_spec["tile_sample_stride_width"], + ] + ) + result_rows.append(torch.cat(result_row, dim=-1)) + + dec = torch.cat(result_rows, dim=3)[ + :, :, :, : grid_spec.tile_spec["sample_height"], : grid_spec.tile_spec["sample_width"] + ] + return dec + + def tiled_decode( + self, + z: torch.Tensor, + temb: torch.Tensor | None = None, + causal: bool | None = None, + return_dict: bool = True, + ): + if not self.is_distributed_enabled(): + if causal is None: + return super().tiled_decode(z, temb, return_dict=return_dict) + return super().tiled_decode(z, temb, causal=causal, return_dict=return_dict) + + logger.debug("LTX2 video VAE decode running with distributed tiled executor") + result = self.distributed_executor.execute( + z, + DistributedOperator( + split=self.tile_split, + exec=lambda task: self.tile_exec(task, temb=temb, causal=causal), + merge=self.tile_merge, + ), + broadcast_result=False, + ) + if not return_dict: + return (result,) + + return DecoderOutput(sample=result) diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py index caa40c93f..f333db8ce 100644 --- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py +++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py @@ -23,7 +23,7 @@ from typing import Any, ClassVar import numpy as np import torch -from diffusers import AutoencoderKLLTX2Audio, AutoencoderKLLTX2Video, FlowMatchEulerDiscreteScheduler +from diffusers import AutoencoderKLLTX2Audio, FlowMatchEulerDiscreteScheduler from diffusers.pipelines.ltx2 import LTX2TextConnectors from diffusers.pipelines.ltx2.vocoder import LTX2Vocoder from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion import retrieve_timesteps @@ -36,6 +36,7 @@ from vllm.logger import init_logger from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_ltx2 import DistributedAutoencoderKLLTX2Video from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin from vllm_omni.diffusion.distributed.parallel_state import ( get_cfg_group, @@ -48,6 +49,7 @@ from vllm_omni.diffusion.model_loader.hub_prefetch import from_pretrained_with_p from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery +from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from .pipeline_ltx2 import ( @@ -113,7 +115,13 @@ def get_ltx2_post_process_func(od_config: OmniDiffusionConfig): return post_process_func -class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, SupportsComponentDiscovery): +class LTX23Pipeline( + nn.Module, + CFGParallelMixin, + ProgressBarMixin, + SupportsComponentDiscovery, + DiffusionPipelineProfilerMixin, +): """Fully independent LTX-2.3 pipeline. Key differences from LTX2Pipeline: @@ -193,7 +201,7 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, SupportsCompo # --- VAE, Audio VAE --- self.vae = from_pretrained_with_prefetch( - AutoencoderKLLTX2Video.from_pretrained, + DistributedAutoencoderKLLTX2Video.from_pretrained, model, subfolder="vae", prefetch_list=ltx2_subfolders, @@ -266,6 +274,10 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, SupportsCompo self._num_timesteps = None self._current_timestep = None + self.setup_diffusion_pipeline_profiler( + enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler + ) + def _place_aux_components(self) -> None: parallel_config = getattr(self.od_config, "parallel_config", None) use_managed_placement = bool( @@ -1253,14 +1265,18 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, SupportsCompo latents = latents.to(self.vae.dtype) video = self.vae.decode(latents, timestep_decode, return_dict=False)[0] - video = self.video_processor.postprocess_video(video, output_type=output_type) + if video.numel() > 0: + video = self.video_processor.postprocess_video(video, output_type=output_type) audio_latents = audio_latents.to(self.audio_vae.dtype) generated_mel_spectrograms = self.audio_vae.decode(audio_latents, return_dict=False)[0] audio = self.vocoder(generated_mel_spectrograms) - return DiffusionOutput(output=(video, audio)) + return DiffusionOutput( + output=(video, audio), + stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, + ) def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: loader = AutoWeightsLoader(self) From e0fde88a5f582113963b1f3ee63997b780b13e9b Mon Sep 17 00:00:00 2001 From: TJian Date: Tue, 9 Jun 2026 22:17:55 +0800 Subject: [PATCH 38/75] [ROCm] [CI] Add group feature and envs feature (#4208) Signed-off-by: tjtanaa --- .buildkite/test-amd-merge.yml | 307 ++++++++++++++------------- .buildkite/test-amd-ready.yaml | 300 +++++++++++++------------- .buildkite/test-template-amd-omni.j2 | 82 ++++++- 3 files changed, 394 insertions(+), 295 deletions(-) diff --git a/.buildkite/test-amd-merge.yml b/.buildkite/test-amd-merge.yml index b986d687c..1a5217b84 100644 --- a/.buildkite/test-amd-merge.yml +++ b/.buildkite/test-amd-merge.yml @@ -1,54 +1,111 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + steps: -- label: "Simple · Diffusion & Model Executor Test" - agent_pool: mi325_1 - depends_on: amd-build - timeout_in_minutes: 50 - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_ROCM_USE_AITER=0 - # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM - # that is not supported on CPU - - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" +- group: ":card_index_dividers: Simple Test" + steps: + - label: "Simple · Diffusion & Model Executor Test" + agent_pool: mi325_1 + depends_on: amd-build + timeout_in_minutes: 50 + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_ROCM_USE_AITER=0 + # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM + # that is not supported on CPU + - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" -- label: "Simple · Other Test" - agent_pool: mi325_1 - depends_on: amd-build - timeout_in_minutes: 50 - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_ROCM_USE_AITER=0 - # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM - # that is not supported on CPU - - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" + - label: "Simple · Other Test" + agent_pool: mi325_1 + depends_on: amd-build + timeout_in_minutes: 50 + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_ROCM_USE_AITER=0 + # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM + # that is not supported on CPU + - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" -- label: "Diffusion Images API LoRA E2E" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 20m pytest -s -v tests/diffusion/lora/test_images_generations_lora.py +- group: ":card_index_dividers: Diffusion Test" + steps: + - label: "Diffusion · LoRA Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 20m pytest -s -v tests/diffusion/lora/test_images_generations_lora.py -- label: "Diffusion Model CPU offloading Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - | - timeout 20m bash -c ' - set +e - pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py - EXIT1=\$? - pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py - EXIT2=\$? - exit \$((EXIT1 | EXIT2)) - ' + - label: "Diffusion Model CPU offloading Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_LOGGING_LEVEL=DEBUG + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - | + timeout 20m bash -c ' + set +e + pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py + EXIT1=\$? + pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py + EXIT2=\$? + exit \$((EXIT1 | EXIT2)) + ' + + - label: "Diffusion Sequence Parallelism Test (Need 4 GPUs)" + agent_pool: mi325_4 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_LOGGING_LEVEL=DEBUG + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py + - timeout 20m pytest -s -v tests/diffusion/distributed/test_ulysses_uaa_perf.py + + # merge-only tests + - label: "Diffusion Tensor Parallelism Test" + agent_pool: mi325_2 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py + + - label: "Diffusion Model Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 30m pytest -s -v tests/e2e/offline_inference/test_z_image.py + + - label: "Diffusion GPU Worker Test" + agent_pool: mi325_2 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py + + - label: "Diffusion · Wan22 Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 40m bash -c " + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' + " ## ISSUE depends on `diffusers` package: https://github.com/huggingface/diffusers/issues/13274 # - label: "Audio Generation Model Test" @@ -62,42 +119,6 @@ steps: # - export VLLM_WORKER_MULTIPROC_METHOD=spawn # - timeout 20m pytest -s -v tests/e2e/offline_inference/test_stable_audio_expansion.py -m "advanced_model and diffusion and L4" --run-level advanced_model -- label: "Diffusion Sequence Parallelism Test (Need 4 GPUs)" - agent_pool: mi325_4 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py - - timeout 20m pytest -s -v tests/diffusion/distributed/test_ulysses_uaa_perf.py - -# merge-only tests -- label: "Diffusion Tensor Parallelism Test" - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - pytest -s -v tests/diffusion/distributed/test_zimage_parallelism.py - -- label: "Diffusion Model Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 30m pytest -s -v tests/e2e/offline_inference/test_z_image.py - -- label: "Diffusion GPU Worker Test" - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py - - label: "Engine Test" agent_pool: mi325_1 depends_on: amd-build @@ -111,50 +132,65 @@ steps: # This model seems to be important, so I am trying to run it here. # Since this is from test-nightly.yml, # I keep this non-blocking. -- label: "Omni Model Test Qwen2-5-Omni" - agent_pool: mi325_4 - depends_on: amd-build - mirror_hardwares: [amdproduction] - commands: - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model" +- group: ":card_index_dividers: E2E Test" + steps: + - label: "Omni Model Test Qwen2-5-Omni" + agent_pool: mi325_4 + depends_on: amd-build + mirror_hardwares: [amdproduction] + commands: + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - pytest -s -v tests/e2e/*/test_qwen2_5_omni_expansion.py --run-level "core_model" -- label: "Omni Model Test Qwen3-Omni" - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' + - label: "Omni Model Test Qwen3-Omni" + agent_pool: mi325_2 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' -- label: "Qwen3-TTS CustomVoice E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 20m bash -c ' - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - 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" --run-level "advanced_model" - ' + - label: "Qwen3-TTS CustomVoice E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 20m bash -c ' + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + 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" --run-level "advanced_model" + ' -- label: "Qwen3-TTS Base E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 30m bash -c ' - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model" - ' + - label: "Qwen3-TTS Base E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 30m bash -c ' + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model" + ' + + - label: "Voxtral-TTS E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 20m bash -c ' + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" + ' # TODO: Bagel test on ROCm is very unstable. @tjtanaa # Need to debug before reneable numerical changes across large PRs @@ -195,28 +231,3 @@ steps: # - export VLLM_WORKER_MULTIPROC_METHOD=spawn # - export VLLM_ROCM_USE_AITER_RMSNORM=0 # - timeout 40m pytest -s -v tests/e2e/online_serving/test_bagel.py -m "advanced_model" --run-level "advanced_model" -k "rocm" - -- label: "Diffusion · Wan22 Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 40m bash -c " - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/offline_inference/test_wan22_t2v.py tests/e2e/online_serving/test_wan22_t2v.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' - " - -- label: "Voxtral-TTS E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 20m bash -c ' - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" - ' diff --git a/.buildkite/test-amd-ready.yaml b/.buildkite/test-amd-ready.yaml index 9951544bd..9da9507c8 100644 --- a/.buildkite/test-amd-ready.yaml +++ b/.buildkite/test-amd-ready.yaml @@ -1,42 +1,33 @@ +env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + HF_HUB_DOWNLOAD_TIMEOUT: 300 + HF_HUB_ETAG_TIMEOUT: 60 + steps: -- label: "Simple · Diffusion & Model Executor Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_ROCM_USE_AITER=0 - # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM - # that is not supported on CPU - - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" +- group: ":card_index_dividers: Simple Test" + steps: + - label: "Simple · Diffusion & Model Executor Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_ROCM_USE_AITER=0 + # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM + # that is not supported on CPU + - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" -- label: "Simple · Other Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_ROCM_USE_AITER=0 - # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM - # that is not supported on CPU - - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - -- label: "Voxtral TTS CUDA Unit Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 10m pytest -s -v tests/model_executor/models/voxtral_tts/test_cuda_graph_acoustic_transformer.py - -- label: "Diffusion · Batch Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 20m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model" + - label: "Simple · Other Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_ROCM_USE_AITER=0 + # ignore test_teacache_extractors.py because it use rocm gemm kernel from vLLM + # that is not supported on CPU + - "timeout 40m pytest -sv tests/diffusion tests/model_executor -m 'core_model and cpu' --ignore=tests/diffusion/cache/test_teacache_extractors.py --cov=vllm_omni --cov-report=term-missing:skip-covered --cov-report=xml" - label: "Custom Pipeline Test" agent_pool: mi325_1 @@ -46,23 +37,92 @@ steps: commands: - timeout 20m pytest -s -v tests/e2e/offline_inference/custom_pipeline/ -m "core_model" -- label: "Diffusion Model CPU offloading Test" + +- label: "Voxtral TTS CUDA Unit Test" agent_pool: mi325_1 depends_on: amd-build mirror_hardwares: [amdproduction] grade: Blocking commands: - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - | - timeout 20m bash -c ' - set +e - pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py - EXIT1=\$? - pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py - EXIT2=\$? - exit \$((EXIT1 | EXIT2)) - ' + - timeout 10m pytest -s -v tests/model_executor/models/voxtral_tts/test_cuda_graph_acoustic_transformer.py + +- group: ":card_index_dividers: Diffusion Test" + steps: + - label: "Diffusion · Batch Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 20m pytest -sv tests/diffusion/batching -m 'core_model and cuda' --run-level "core_model" + + - label: "Diffusion · Offloader Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_LOGGING_LEVEL=DEBUG + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - | + timeout 20m bash -c ' + set +e + pytest -s -v tests/diffusion/offloader/test_diffusion_cpu_offload.py + EXIT1=\$? + pytest -s -v tests/diffusion/offloader/test_diffusion_layerwise_offload.py + EXIT2=\$? + exit \$((EXIT1 | EXIT2)) + ' + + - label: "Diffusion · Cache Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export GPU_ARCHS=gfx942 + - export VLLM_LOGGING_LEVEL=DEBUG + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - timeout 15m pytest -s -v -m "core_model and cache and diffusion and not distributed_cuda and L4" + + - label: "Diffusion Sequence Parallelism Test" + agent_pool: mi325_2 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - 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 + + - label: "Diffusion GPU Worker Test" + agent_pool: mi325_2 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py + + - label: "Diffusion · Model Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" + + - label: "Diffusion · Wan22 Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 40m bash -c " + export VLLM_TEST_CLEAN_GPU_MEMORY=1 + export VLLM_IMAGE_FETCH_TIMEOUT=60 + pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model' + " ## ISSUE depends on `diffusers` package: https://github.com/huggingface/diffusers/issues/13274 # - label: "Audio Generation Model Test" @@ -76,56 +136,6 @@ steps: # - export VLLM_WORKER_MULTIPROC_METHOD=spawn # - timeout 20m pytest -s -v tests/e2e/offline_inference/test_stable_audio_expansion.py -m "advanced_model and diffusion and L4" --run-level advanced_model -- label: "Diffusion Cache Backend Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export GPU_ARCHS=gfx942 - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - timeout 15m pytest -s -v -m "core_model and cache and diffusion and not distributed_cuda and L4" - -- label: "Diffusion Sequence Parallelism Test" - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - 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 - -- label: "Diffusion GPU Worker Test" - agent_pool: mi325_2 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 20m pytest -s -v tests/diffusion/test_diffusion_worker.py - -- label: "Diffusion · Model Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" - -- label: "Diffusion · Wan22 Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 40m bash -c " - export VLLM_TEST_CLEAN_GPU_MEMORY=1 - export VLLM_IMAGE_FETCH_TIMEOUT=60 - pytest -s -v tests/e2e/online_serving/test_wan22_t2v.py -m 'core_model' --run-level 'core_model' - " - - label: "Engine Test" agent_pool: mi325_1 depends_on: amd-build @@ -149,43 +159,56 @@ steps: # - timeout 10m pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py # - timeout 20m pytest -s -v tests/e2e/online_serving/test_qwen3_omni.py -m "core_model" --run-level "core_model" -- label: "MiMo-Audio E2E Test with H100" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 30m bash -c ' - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m "core_model" --run-level "core_model" - ' +- group: ":card_index_dividers: E2E Test" + steps: + - label: "MiMo-Audio E2E Test with H100" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 30m bash -c ' + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m "core_model" --run-level "core_model" + ' -- label: "Qwen3-TTS E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - export VLLM_LOGGING_LEVEL=DEBUG - - export VLLM_WORKER_MULTIPROC_METHOD=spawn - - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" - - timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model" + - label: "Qwen3-TTS E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - export VLLM_LOGGING_LEVEL=DEBUG + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model" -- label: "Voxtral-TTS E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 20m bash -c ' - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" - pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" - ' + - label: "Voxtral-TTS E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 20m bash -c ' + export VLLM_LOGGING_LEVEL=DEBUG + export VLLM_WORKER_MULTIPROC_METHOD=spawn + pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" + pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model" + ' + + - label: "CosyVoice3-TTS E2E Test" + agent_pool: mi325_1 + depends_on: amd-build + mirror_hardwares: [amdproduction] + grade: Blocking + commands: + - | + timeout 20m bash -c ' + pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "full_model" --run-level "core_model" + ' # TODO: Bagel test on ROCm is very unstable. @tjtanaa # Need to debug before reneable numerical changes across large PRs @@ -225,14 +248,3 @@ steps: # - export VLLM_WORKER_MULTIPROC_METHOD=spawn # - export VLLM_ROCM_USE_AITER_RMSNORM=0 # - timeout 40m pytest -s -v tests/e2e/online_serving/test_bagel.py -m "core_model" --run-level "core_model" -k "rocm" - -- label: "CosyVoice3-TTS E2E Test" - agent_pool: mi325_1 - depends_on: amd-build - mirror_hardwares: [amdproduction] - grade: Blocking - commands: - - | - timeout 20m bash -c ' - pytest -s -v tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py -m "full_model" --run-level "core_model" - ' diff --git a/.buildkite/test-template-amd-omni.j2 b/.buildkite/test-template-amd-omni.j2 index db76f8216..aa5ca8bcd 100644 --- a/.buildkite/test-template-amd-omni.j2 +++ b/.buildkite/test-template-amd-omni.j2 @@ -6,6 +6,13 @@ {% set docker_image_amd = "rocm/vllm-omni:$BUILDKITE_COMMIT" %} {% set default_working_dir = "/app/vllm-omni" %} +{% if env %} +env: +{% for name in env %} + {{ name }}: "{{ env[name] }}" +{% endfor %} +{% endif %} +steps: - group: "AMD Tests" depends_on: ~ steps: @@ -31,8 +38,15 @@ agents: queue: amd-cpu - {% for step in steps %} - {% if step.mirror_hardwares and mirror_hw in step.mirror_hardwares %} +{% for step_group in steps %} +{% if step_group.group %} + - group: "{{ step_group.group }}" +{% if step_group.depends_on %} + depends_on: {{ step_group.depends_on }} +{% endif %} + steps: +{% for step in step_group.steps %} +{% if step.mirror_hardwares and mirror_hw in step.mirror_hardwares %} - label: "{{ step.agent_pool }}: {{ step.label }}" depends_on: amd-build agents: @@ -46,8 +60,23 @@ command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh env: DOCKER_BUILDKIT: "1" +{% if step.env %} +{% for name in step.env %} + {{ name }}: "{{ step.env[name] }}" +{% endfor %} +{% endif %} TEST_COMMAND: |- (command rocm-smi || true) && cd {{ (step.working_dir or default_working_dir) | safe }} +{% if env %} +{% for name in env %} + export {{ name }}="{{ env[name] }}" +{% endfor %} +{% endif %} +{% if step.env %} +{% for name in step.env %} + export {{ name }}="{{ step.env[name] }}" +{% endfor %} +{% endif %} {% if "mi250" in step.agent_pool %} python3 -m pip uninstall -y amd-aiter {% endif %} @@ -61,5 +90,52 @@ {% else %} soft_fail: true {% endif%} +{% endif %} +{% endfor %} +{% elif step_group.mirror_hardwares and mirror_hw in step_group.mirror_hardwares %} +{% set step = step_group %} + - label: "{{ step.agent_pool }}: {{ step.label }}" + depends_on: amd-build + agents: + {% if step.agent_pool %} + queue: amd_{{ step.agent_pool }} + {% else %} + queue: amd_mi325_1 + {% endif %} +{% set cmd_body = (step.command or (step.commands | join("\n"))) | trim %} +{% set indented_cmd = cmd_body | replace("\n", "\n ") %} + command: bash .buildkite/scripts/hardware_ci/run-amd-test.sh + env: + DOCKER_BUILDKIT: "1" +{% if step.env %} +{% for name in step.env %} + {{ name }}: "{{ step.env[name] }}" +{% endfor %} +{% endif %} + TEST_COMMAND: |- + (command rocm-smi || true) && cd {{ (step.working_dir or default_working_dir) | safe }} +{% if env %} +{% for name in env %} + export {{ name }}="{{ env[name] }}" +{% endfor %} +{% endif %} +{% if step.env %} +{% for name in step.env %} + export {{ name }}="{{ step.env[name] }}" +{% endfor %} +{% endif %} +{% if "mi250" in step.agent_pool %} + python3 -m pip uninstall -y amd-aiter +{% endif %} + {{ indented_cmd | safe }} + priority: 100 + {% if step.timeout_in_minutes %} + timeout_in_minutes: {{ step.timeout_in_minutes }} {% endif %} - {% endfor %} + {% if step.grade and step.grade == "Blocking" %} + soft_fail: false + {% else %} + soft_fail: true + {% endif%} +{% endif %} +{% endfor %} From 3753b21c3d4a0581a08c7025ce7421e62a486c91 Mon Sep 17 00:00:00 2001 From: "Guanzhe (Leo) Huang" <116847811+leohuang257@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:36:35 +0800 Subject: [PATCH 39/75] [Feature] LoRA support for SenseNova-U1 (#3971) Signed-off-by: leohuang257 Signed-off-by: Guanzhe (Leo) Huang <116847811+leohuang257@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Yuanheng Zhao <54058983+yuanheng-zhao@users.noreply.github.com> --- .buildkite/test-nightly.yml | 1 + .../test_sensenova_u1_expansion.py | 160 ++++++++++++++++++ vllm_omni/diffusion/lora/manager.py | 6 +- .../sensenova_u1/pipeline_sensenova_u1.py | 5 + 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/offline_inference/test_sensenova_u1_expansion.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index ef99257fb..a324d6bbf 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -591,6 +591,7 @@ steps: tests/e2e/online_serving/test_longcat_image_edit_expansion.py tests/e2e/online_serving/test_flux_2_dev_expansion.py tests/e2e/online_serving/test_bagel_expansion.py + tests/e2e/offline_inference/test_sensenova_u1_expansion.py -m "full_model and diffusion and H100 and not distributed_cuda" --run-level "full_model" agents: diff --git a/tests/e2e/offline_inference/test_sensenova_u1_expansion.py b/tests/e2e/offline_inference/test_sensenova_u1_expansion.py new file mode 100644 index 000000000..1f9769f81 --- /dev/null +++ b/tests/e2e/offline_inference/test_sensenova_u1_expansion.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +""" +Expanded end-to-end test for SenseNova-U1 in offline mode. + +This test file primarily covers end-to-end tests for LoRA support. + +Validates that LoRA adapters are correctly loaded, applied with controllable +scale, and cleanly deactivated. Uses a synthetic rank-4 adapter targeting the +first decoder layer's gen-path (``_mot_gen``) output projection, so the +perturbation is exercised during the denoising loop. + +Assertions: + (a) LoRA at scale=1.0 visibly changes the output (diff > 0.5) + (b) scale=2.0 produces a different delta than scale=1.0 (scale sensitivity) + (c) The delta is bounded (not corrupted) + (d) Deactivating LoRA exactly restores the baseline (diff == 0) + +Observed on H100 (seed=42, 8 steps, 512x512): diff_1x~62.2, diff_2x~76.9, +diff_restored=0.0. Thresholds match test_bagel_expansion.py and hold with margin. +""" + +import json +import os +from pathlib import Path + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import numpy as np +import pytest +import torch +from PIL import Image +from safetensors.torch import save_file + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner +from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.lora.request import LoRARequest +from vllm_omni.lora.utils import stable_lora_int_id + +MODEL = "SenseNova/SenseNova-U1-8B-MoT" +DEFAULT_PROMPT = "a photo of a cat sitting on a laptop keyboard" +SIZE = (512, 512) # (width, height); small for fast E2E + +# SenseNova-U1-8B-MoT hidden_size (Qwen3-based). o_proj is square (hidden x hidden) +# regardless of GQA, so this is the robust LoRA target. Verify against config.json. +_LORA_DIM = 4096 +_LORA_MODULE = "language_model.model.layers.0.self_attn.o_proj_mot_gen" +_LORA_RANK = 4 + + +# --------------------------------------------------------------------------- +# Helpers (reused from test_sensenova_u1_text2img.py patterns) +# --------------------------------------------------------------------------- + + +def _build_sampling_params( + lora_request: LoRARequest | None = None, + lora_scale: float = 1.0, +) -> OmniDiffusionSamplingParams: + return OmniDiffusionSamplingParams( + height=SIZE[1], + width=SIZE[0], + seed=42, + num_inference_steps=8, + extra_args={ + "cfg_scale": 4.0, + "cfg_norm": "none", + "timestep_shift": 3.0, + "cfg_interval": (0.0, 1.0), + "batch_size": 1, + "think": False, + "t_eps": 0.02, + }, + lora_request=lora_request, + lora_scale=lora_scale, + ) + + +def _extract_generated_image(omni_outputs: list) -> Image.Image | None: + for req_output in omni_outputs: + if images := getattr(req_output, "images", None): + return images[0] + return None + + +def _generate(omni: Omni, lora_request: LoRARequest | None = None, lora_scale: float = 1.0) -> Image.Image: + outputs = list( + omni.generate( + prompts={"prompt": DEFAULT_PROMPT, "modalities": ["image"]}, + sampling_params_list=_build_sampling_params(lora_request, lora_scale), + ) + ) + img = _extract_generated_image(outputs) + assert img is not None, "No image generated" + return img + + +def _make_file_lora_request(adapter_dir: Path) -> LoRARequest: + """Write synthetic adapter to disk and return a file-backed LoRARequest.""" + adapter_dir.mkdir(parents=True, exist_ok=True) + gen = torch.Generator().manual_seed(42) + lora_a = torch.randn((_LORA_RANK, _LORA_DIM), dtype=torch.float32, generator=gen) * 0.1 + lora_b = torch.randn((_LORA_DIM, _LORA_RANK), dtype=torch.float32, generator=gen) * 0.5 + save_file( + { + f"base_model.model.{_LORA_MODULE}.lora_A.weight": lora_a, + f"base_model.model.{_LORA_MODULE}.lora_B.weight": lora_b, + }, + str(adapter_dir / "adapter_model.safetensors"), + ) + (adapter_dir / "adapter_config.json").write_text( + json.dumps({"r": _LORA_RANK, "lora_alpha": _LORA_RANK, "target_modules": [_LORA_MODULE]}), + encoding="utf-8", + ) + lora_dir = str(adapter_dir) + return LoRARequest(lora_name="test_file", lora_int_id=stable_lora_int_id(lora_dir), lora_path=lora_dir) + + +# --------------------------------------------------------------------------- +# Test +# --------------------------------------------------------------------------- + + +@pytest.mark.full_model +@pytest.mark.diffusion +@hardware_test(res={"cuda": "H100"}) +def test_sensenova_u1_lora_scale_and_deactivation(tmp_path) -> None: + """Validate LoRA effect, scale sensitivity, bounded perturbation, and clean deactivation.""" + lora_request = _make_file_lora_request(tmp_path / "sensenova_lora") + + with OmniRunner(MODEL, stage_configs_path=None) as runner: + omni = runner.omni + baseline = _generate(omni) + img_1x = _generate(omni, lora_request, lora_scale=1.0) + img_2x = _generate(omni, lora_request, lora_scale=2.0) + restored = _generate(omni) + + baseline_arr = np.array(baseline, dtype=np.int16) + diff_1x = np.abs(baseline_arr - np.array(img_1x, dtype=np.int16)).mean() + diff_2x = np.abs(baseline_arr - np.array(img_2x, dtype=np.int16)).mean() + diff_restored = np.abs(baseline_arr - np.array(restored, dtype=np.int16)).mean() + + # (a) Adapter has visible effect at both scales + assert diff_1x > 0.5, f"LoRA scale=1.0 had no visible effect: diff={diff_1x}" + assert diff_2x > 0.5, f"LoRA scale=2.0 had no visible effect: diff={diff_2x}" + + # (b) Different scales produce different outputs + assert not np.isclose(diff_1x, diff_2x, atol=1.0), ( + f"LoRA scale has no effect: diff_1x={diff_1x:.2f}, diff_2x={diff_2x:.2f}" + ) + + # (c) Output is not corrupted + assert diff_1x < 80, f"LoRA output looks corrupted: diff_1x={diff_1x}" + assert diff_2x < 120, f"LoRA output looks corrupted: diff_2x={diff_2x}" + + # (d) Deactivation fully restores base model + assert diff_restored == 0.0, f"Base model not restored after LoRA deactivation: diff={diff_restored}" diff --git a/vllm_omni/diffusion/lora/manager.py b/vllm_omni/diffusion/lora/manager.py index 63e8d9a96..8ba30b79e 100644 --- a/vllm_omni/diffusion/lora/manager.py +++ b/vllm_omni/diffusion/lora/manager.py @@ -366,7 +366,11 @@ class DiffusionLoRAManager: fully_sharded_loras=False, ) - for component_name in ("transformer", "transformer_2", "dit", "bagel"): + # Default denoising components plus any a pipeline opts into via + # ``_lora_components`` (opt-in; other pipelines unchanged). + default_components = ("transformer", "transformer_2", "dit", "bagel") + extra_components = tuple(getattr(self.pipeline, "_lora_components", ()) or ()) + for component_name in (*default_components, *extra_components): if not hasattr(self.pipeline, component_name): continue component = getattr(self.pipeline, component_name) 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 fe8695b07..fa7f95c66 100644 --- a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py +++ b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py @@ -536,6 +536,10 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli _vae_modules: ClassVar[list[str]] = [] _resident_modules: ClassVar[list[str]] = ["fm_modules"] + # Top-level module(s) the diffusion LoRA manager scans (both MoT branches). + # TODO: promote to a shared LoRA contract/mixin instead of per-pipeline opt-in. + _lora_components: ClassVar[list[str]] = ["language_model"] + def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): super().__init__() self.od_config = od_config @@ -1454,6 +1458,7 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli (".gate_up_proj", ".gate_proj", 0), (".gate_up_proj", ".up_proj", 1), ] + self.stacked_params_mapping = stacked_params_mapping params_dict = dict(self.named_parameters()) loaded_params: set[str] = set() From ca75623ff3c95cb22b45ab3af487f50fcc6f2c30 Mon Sep 17 00:00:00 2001 From: Zeng Chuang Date: Tue, 9 Jun 2026 23:30:25 +0800 Subject: [PATCH 40/75] [BugFix] Fix HunyuanImage3 CoT truncation when stop token stripped by detokenizer (#4260) Signed-off-by: zengchuang --- tests/e2e/accuracy/test_hunyuan_image3.py | 46 ++++++++++++++++++++++- vllm_omni/deploy/hunyuan_image_3_moe.yaml | 1 + 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/e2e/accuracy/test_hunyuan_image3.py b/tests/e2e/accuracy/test_hunyuan_image3.py index 7ed3c85a0..620254cf9 100644 --- a/tests/e2e/accuracy/test_hunyuan_image3.py +++ b/tests/e2e/accuracy/test_hunyuan_image3.py @@ -29,7 +29,46 @@ from tests.e2e.accuracy.helpers import ( ) from tests.helpers.mark import hardware_test from tests.helpers.runtime import OmniRunner, OmniServer -from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import build_prompt_tokens, resolve_stop_token_ids +from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import ( + build_prompt_tokens, + resolve_stop_token_ids, +) + +# CoT structural markers that MUST appear in AR output text. +# The AR trajectory under think_recaption mode is: +# ... ... ... +# ... +# Test deploy config sets include_stop_str_in_output=True and +# skip_special_tokens=False, so ALL opening and closing markers +# should be present in the detokenized text. +_THINK_CLOSE = "" # avoid Python slash-t-as-TAB pitfall +_COT_REQUIRED_MARKERS = ( + "", + "", + "", + "", + "", + "", + _THINK_CLOSE, +) + + +def _assert_cot_structural_markers(cot_text: str, label: str) -> None: + """Assert that the AR CoT output contains all required structural tags. + + Guards against regressions where detokenizer stop-token stripping + or truncation logic loses CoT content. Under the test deploy config + (include_stop_str_in_output=True, skip_special_tokens=False), + ALL markers should be present in the detokenized text. + """ + missing = [m for m in _COT_REQUIRED_MARKERS if m not in cot_text] + assert not missing, ( + f"[{label}] CoT text missing required structural markers: {missing}. " + f"This typically indicates that stop-token stripping or " + f"_truncate_at_cot_end truncation removed CoT content. " + f"CoT text length={len(cot_text)}, first 100 chars: {repr(cot_text[:100])}" + ) + os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA" @@ -129,6 +168,7 @@ _DEPLOY_CONFIG = { "stop_token_ids": [128025], "detokenize": True, "skip_special_tokens": False, + "include_stop_str_in_output": True, }, }, { @@ -400,6 +440,8 @@ def test_image_to_image_alignment_online(accuracy_artifact_root: Path, accuracy_ ] print("[ONLINE] " + tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid")) + _assert_cot_structural_markers(online_cot, "ONLINE") + assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], ( f"[ONLINE] COT semantic similarity {cot_results['cot_semantic_sim']:.4f} below threshold {THRESHOLDS['cot_semantic_sim']}" ) @@ -508,6 +550,8 @@ def test_image_to_image_alignment(accuracy_artifact_root: Path, accuracy_assets_ print(tabulate(table, headers=["Metric", "Value", "L20x Reference"], tablefmt="grid")) + _assert_cot_structural_markers(omni_cot, "OFFLINE") + assert cot_results["cot_semantic_sim"] >= THRESHOLDS["cot_semantic_sim"], ( f"COT semantic similarity {cot_results['cot_semantic_sim']:.4f} is below threshold {THRESHOLDS['cot_semantic_sim']}" ) diff --git a/vllm_omni/deploy/hunyuan_image_3_moe.yaml b/vllm_omni/deploy/hunyuan_image_3_moe.yaml index a6e2add17..67ce8b66a 100644 --- a/vllm_omni/deploy/hunyuan_image_3_moe.yaml +++ b/vllm_omni/deploy/hunyuan_image_3_moe.yaml @@ -62,6 +62,7 @@ stages: max_tokens: 8192 detokenize: true skip_special_tokens: false + include_stop_str_in_output: true, - stage_id: 1 max_num_seqs: 1 From 57227dc7f32e61110b120ab82931cfc51a65c65e Mon Sep 17 00:00:00 2001 From: Juan Pablo Zuluaga <46724788+JuanPZuluaga@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:36:57 +0200 Subject: [PATCH 41/75] [Perf][Bugfix] qwen3-tts hot path: prefix-cache OOM guards + talker/orchestrator micro-opts (#3689) Signed-off-by: JuanPZuluaga Co-authored-by: JuanPZuluaga Co-authored-by: Gao Han --- tests/worker/test_omni_gpu_model_runner.py | 1 + vllm_omni/core/prefix_cache.py | 28 ++++++++++++++++++- vllm_omni/engine/orchestrator.py | 2 +- .../qwen3_tts/cuda_graph_decoder_wrapper.py | 9 ++++-- .../models/qwen3_tts/qwen3_tts_code2wav.py | 3 +- .../models/qwen3_tts/qwen3_tts_talker.py | 5 ++-- .../modeling_qwen3_tts_tokenizer_v2.py | 13 ++++++++- vllm_omni/worker/gpu_model_runner.py | 6 ++-- 8 files changed, 54 insertions(+), 13 deletions(-) diff --git a/tests/worker/test_omni_gpu_model_runner.py b/tests/worker/test_omni_gpu_model_runner.py index ae6ecc57c..6f4994b2c 100644 --- a/tests/worker/test_omni_gpu_model_runner.py +++ b/tests/worker/test_omni_gpu_model_runner.py @@ -22,6 +22,7 @@ class DummyInputBatch: def __init__(self, req_ids): self.req_ids = req_ids + self.req_id_to_index = {r: i for i, r in enumerate(req_ids)} class DummyReqState: diff --git a/vllm_omni/core/prefix_cache.py b/vllm_omni/core/prefix_cache.py index 25acfe8ed..5e127fd44 100644 --- a/vllm_omni/core/prefix_cache.py +++ b/vllm_omni/core/prefix_cache.py @@ -10,6 +10,9 @@ from vllm_omni.utils.mm_outputs import build_mm_cpu, to_payload_element logger = init_logger(__name__) +# Skip oversized per-key caches; otherwise hidden_states.layer_N can OOM on big models. +_MAX_MM_CACHE_BYTES_PER_KEY = 512 * 1024 * 1024 + class OmniTensorPrefixCache: """Prefix cache for hidden states (model outputs) and model specific @@ -46,6 +49,7 @@ class OmniTensorPrefixCache: # actually see mm output tensors dependent on num tokens. self.mm_outputs_cache = {} self.mm_cache_keys = set() + self._mm_oversized_keys: set[str] = set() self._new_req_cache_hit_ids: set[str] = set() self._deferred_mm_outputs: dict[str, dict[str, list[tuple[int, torch.Tensor]]]] = {} @@ -75,6 +79,17 @@ class OmniTensorPrefixCache: and key not in self.mm_cache_keys ): feat_dim = val.shape[-1] + # Bound per-key cache to avoid OOM on wide per-token features. + key_bytes = self.num_blocks * self.block_size * feat_dim * val.element_size() + if key_bytes > _MAX_MM_CACHE_BYTES_PER_KEY: + logger.warning_once( + "Skipping mm prefix cache key '%s': %.1f MiB > %.1f MiB cap.", + key, + key_bytes / 2**20, + _MAX_MM_CACHE_BYTES_PER_KEY / 2**20, + ) + self._mm_oversized_keys.add(key) + continue self.mm_outputs_cache[key] = self._get_cache_tensor( dtype=val.dtype, hidden_size=feat_dim, @@ -417,7 +432,18 @@ class OmniTensorPrefixCache: # tensors similarly to the non prefix cached path, and then populate # the subdicts mapping request IDs -> payload objects passthrough_keys = set(mm_outputs.keys()) - self.mm_cache_keys - passthrough_mm_data = {k: v for k, v in mm_outputs.items() if k in passthrough_keys} + total_scheduled_tokens = sum(int(num_scheduled_tokens[r]) for r in input_batch.req_ids) + passthrough_mm_data: dict[str, object] = {} + for k in passthrough_keys: + v = mm_outputs[k] + if ( + k in self._mm_oversized_keys + and isinstance(v, torch.Tensor) + and v.dim() >= 2 + and v.shape[0] >= total_scheduled_tokens + ): + v = v[:total_scheduled_tokens] + passthrough_mm_data[k] = v mm_cpu = build_mm_cpu(multimodal_outputs=passthrough_mm_data) for mm_key, mm_val in mm_cpu.items(): diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index ea20c650b..ae4d2bbcd 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -689,7 +689,7 @@ class Orchestrator: await self._handle_kv_ready_raw_outputs(stage_id, raw_outputs) for eco in raw_outputs.outputs: req_state = self.request_states.get(getattr(eco, "request_id", None)) - if req_state is None: + if req_state is None or not req_state.streaming.enabled: continue req_state.streaming.segment_finished = bool(getattr(eco, "is_segment_finished", False)) req_state.streaming.new_prompt_len_snapshot = getattr( diff --git a/vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py b/vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py index 1c25e0842..604946a35 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py +++ b/vllm_omni/model_executor/models/qwen3_tts/cuda_graph_decoder_wrapper.py @@ -7,6 +7,7 @@ This module provides CUDA Graph acceleration for the speech tokenizer decoder, reducing kernel launch overhead during inference. """ +import bisect import os import time from collections import Counter @@ -225,9 +226,11 @@ class CUDAGraphDecoderWrapper: return sorted(sizes) def _get_padded_size(self, actual_size: int) -> int | None: - for size in self._bucket_sizes: - if actual_size <= size: - return size + # bisect_left over the pre-sorted _bucket_sizes is O(log n) vs the linear scan; + # this matters because _decode invokes it on every per-chunk replay. + idx = bisect.bisect_left(self._bucket_sizes, actual_size) + if idx < len(self._bucket_sizes): + return self._bucket_sizes[idx] return None def _get_capture_shapes(self) -> list[tuple[int, int]]: 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 7991d85a1..6a5866c7e 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 @@ -545,7 +545,8 @@ class Qwen3TTSCode2Wav(nn.Module): continue wav = wav[start : min(end, wav.shape[0])] if wav.shape[0] > 0: - audios[idx] = wav.to(dtype=torch.float32).reshape(-1) + # Decoder already runs in fp32, so the .to(float32) is a redundant dispatch. + audios[idx] = (wav if wav.dtype == torch.float32 else wav.to(torch.float32)).reshape(-1) for req_id, finished in zip(ref_context_request_ids, finished_flags, strict=False): if req_id is not None and finished: 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 d97f21233..541efb580 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 @@ -368,6 +368,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): ) # Constant logit mask: allow only codec ids [1, codebook_vocab_size) plus codec EOS. + # Register the *disallowed* form so compute_logits doesn't recompute ~mask per step. vocab = int(self.talker_config.vocab_size) codec_mask = torch.zeros((vocab,), dtype=torch.bool) lo, hi = 1, min(self._codebook_vocab_size, vocab) @@ -375,7 +376,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): codec_mask[lo:hi] = True if 0 <= self._codec_eos_token_id < vocab: codec_mask[self._codec_eos_token_id] = True - self.register_buffer("_codec_allowed_mask", codec_mask, persistent=False) + self.register_buffer("_codec_disallowed_mask", ~codec_mask, persistent=False) # Keys that should stay on GPU in model_intermediate_buffer to avoid # CPU-to-GPU round-trips on every decode step. @@ -513,7 +514,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): return None # Mask out invalid codec ids using the pre-built constant buffer. - logits = logits.masked_fill(~self._codec_allowed_mask, float("-inf")) + logits = logits.masked_fill(self._codec_disallowed_mask, float("-inf")) if self._eos_logit_bias != 0.0: eos_id = self._codec_eos_token_id diff --git a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_12hz/modeling_qwen3_tts_tokenizer_v2.py b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_12hz/modeling_qwen3_tts_tokenizer_v2.py index 99b26ea1e..6eb78eeae 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/tokenizer_12hz/modeling_qwen3_tts_tokenizer_v2.py +++ b/vllm_omni/model_executor/models/qwen3_tts/tokenizer_12hz/modeling_qwen3_tts_tokenizer_v2.py @@ -198,10 +198,21 @@ class Qwen3TTSTokenizerV2CausalConvNet(nn.Module): self.padding = self.kernel_size - self.stride def _get_extra_padding_for_conv1d(self, hidden_state: torch.Tensor) -> int: + # Cache the result per input length — under cudagraph capture the set of lengths + # is fixed, so the LUT converges quickly and replaces the math.ceil call per replay. length = hidden_state.shape[-1] + cache = self.__dict__.get("_pad_lut") + if cache is None: + cache = {} + self.__dict__["_pad_lut"] = cache + cached = cache.get(length) + if cached is not None: + return cached n_frames = (length - self.kernel_size + self.padding) / self.stride + 1 ideal_length = (math.ceil(n_frames) - 1) * self.stride + (self.kernel_size - self.padding) - return ideal_length - length + extra_padding = ideal_length - length + cache[length] = extra_padding + return extra_padding def forward(self, hidden_state): extra_padding = self._get_extra_padding_for_conv1d(hidden_state) diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index 70f1f58dc..abef62f31 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -1790,10 +1790,8 @@ class OmniGPUModelRunner(GPUModelRunner): if not isinstance(out_key, tuple) or len(out_key) != 2: raise TypeError(f"talker_mtp_output_key must be a 2-tuple, got {type(out_key).__name__}: {out_key!r}") if start_offsets is None: - start_offsets = [] - for req_id in decode_req_ids: - req_index = self.input_batch.req_ids.index(req_id) - start_offsets.append(int(self.query_start_loc.cpu[req_index])) + 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] update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}} From e1b0b83238e7f1c3798eb4806cdfa9bb65413957 Mon Sep 17 00:00:00 2001 From: inkcherry Date: Wed, 10 Jun 2026 08:37:33 +0800 Subject: [PATCH 42/75] add moriio transfer engine (#1742) Signed-off-by: inkcherry Signed-off-by: zejwang Signed-off-by: junkang1991 Signed-off-by: Zejian Wang Co-authored-by: zejwang Co-authored-by: junkang1991 Co-authored-by: Zejian Wang Co-authored-by: Zejian Wang Co-authored-by: Cursor --- .../design/feature/disaggregated_inference.md | 2 + .../mori_transfer_engine_connector.md | 76 ++ .../deploy/qwen3_omni_moe_mori_intranode.yaml | 107 ++ vllm_omni/distributed/__init__.py | 2 + .../distributed/omni_connectors/__init__.py | 6 + .../mori_transfer_engine_connector.py | 962 ++++++++++++++++++ .../distributed/omni_connectors/factory.py | 12 + .../omni_connectors/utils/config.py | 1 + vllm_omni/engine/async_omni_engine.py | 1 + 9 files changed, 1169 insertions(+) create mode 100644 docs/design/feature/omni_connectors/mori_transfer_engine_connector.md create mode 100644 vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml create mode 100644 vllm_omni/distributed/omni_connectors/connectors/mori_transfer_engine_connector.py diff --git a/docs/design/feature/disaggregated_inference.md b/docs/design/feature/disaggregated_inference.md index 755d6aeea..15d9e09df 100644 --- a/docs/design/feature/disaggregated_inference.md +++ b/docs/design/feature/disaggregated_inference.md @@ -8,6 +8,7 @@ Backend-specific setup lives in separate docs: - [SharedMemoryConnector](omni_connectors/shared_memory_connector.md) - [MooncakeStoreConnector](omni_connectors/mooncake_store_connector.md) - [MooncakeTransferEngineConnector](omni_connectors/mooncake_transfer_engine_connector.md) +- [MoriTransferEngineConnector](omni_connectors/mori_transfer_engine_connector.md) - [YuanrongConnector](omni_connectors/yuanrong_connector.md) - [YuanrongTransferEngineConnector](omni_connectors/yuanrong_transfer_engine_connector.md) @@ -23,6 +24,7 @@ Current connectors operate in D2H2D (device to host to device) mode. | Single node | SharedMemoryConnector | Auto-configured if no connector is specified. | | Multi node (Mooncake Store) | MooncakeStoreConnector | TCP-based, requires Mooncake Master + metadata server. | | Multi node (Mooncake RDMA) | MooncakeTransferEngineConnector | RDMA/TCP direct transfer with managed memory pool. Fastest. | +| Multi node (Mori RDMA) | MoriTransferEngineConnector | RDMA direct transfer via Mori IOEngine. | | Multi node (Yuanrong) | YuanrongConnector | Requires Yuanrong Datasystem + etcd. | | Ascend NPU P2P (Yuanrong TE) | YuanrongTransferEngineConnector | Uses Yuanrong TransferEngine directly. Configure NPU device IPv4 and `memory_pool_device: "npu"`. | diff --git a/docs/design/feature/omni_connectors/mori_transfer_engine_connector.md b/docs/design/feature/omni_connectors/mori_transfer_engine_connector.md new file mode 100644 index 000000000..73212c6de --- /dev/null +++ b/docs/design/feature/omni_connectors/mori_transfer_engine_connector.md @@ -0,0 +1,76 @@ +# MoriTransferEngineConnector + +## When to Use + +Currently supports intra-node deployment with Mori. + +As noted in #1742, inter-node support will be added back in a future +refactor. + +## Mechanism + +Uses Mori's `IOEngine` / `MemoryDesc` API for zero-copy RDMA transfers. + +- Data Plane: RDMA (InfiniBand/RoCE) with managed memory pool. +- Control Plane: ZMQ for pull-request handshake and async completion. + +## Installation + +See the [Mori repository](https://github.com/ROCm/mori) for installation instructions. + +## Configuration + +Mori is configured through the new deploy-config schema (see +[`docs/configuration/stage_configs.md`](../../../configuration/stage_configs.md)). +Define the connector at the top level of the deploy YAML and reference it +by name from each stage's `input_connectors` / `output_connectors`: + +```yaml +connectors: + mori_connector: + name: MoriTransferEngineConnector + extra: + host: "auto" + zmq_port: 50051 + device_name: "" + memory_pool_size: 536870912 + memory_pool_device: "cuda" + +stages: + - stage_id: 0 + output_connectors: + to_stage_1: mori_connector + + - stage_id: 1 + input_connectors: + from_stage_0: mori_connector +``` + +A ready-to-run intra-node example for Qwen3-Omni-MoE on AMD MI300X lives +at `vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml` and can be loaded +with: + +```bash +vllm-omni serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --log-stats \ + --deploy-config vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml +``` + +The yaml wires `MoriTransferEngineConnector` (with `backend_type: xgmi`) to +the chunk_transfer_adapter path (`async_chunk: true`) so stage-to-stage +hidden-state and codec-frame streams ship GPU-to-GPU over AMD Infinity +Fabric instead of SHM. Qwen2.5-Omni + Mori is not yet functional on +the chunk path: it needs `thinker2talker_async_chunk` / +`talker2code2wav_async_chunk` input processors that do not exist yet +(the orchestrator-level path the upstream PR originally targeted was +lost during the entrypoints → engine refactor and removed in #1742). + +Parameters: + +- host: local RDMA IP (`"auto"` for auto-detect). +- zmq_port: ZMQ base port for control-plane communication. +- device_name: RDMA device (e.g., `"mlx5_0"`), empty for auto-detect. +- memory_pool_size: RDMA memory pool size in bytes. +- memory_pool_device: `"cpu"` (pinned) or `"cuda"` (GPUDirect / XGMI RDMA). + +For more details, refer to the +[Mori repository](https://github.com/ROCm/mori). diff --git a/vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml b/vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml new file mode 100644 index 000000000..5c0d51e5a --- /dev/null +++ b/vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml @@ -0,0 +1,107 @@ +# Qwen3-Omni-MoE intra-node deploy using Mori XGMI transfer. +# +# Targets a single AMD Instinct MI300X OAM node (8x GPU, 192GB HBM3, +# gfx942). Each stage occupies one dedicated GPU (3 GPUs total) and +# stage-to-stage streaming chunks are shipped via Mori's RDMA IOEngine +# (with ``backend_type: xgmi`` selecting the AMD Infinity Fabric +# GPU-to-GPU direct path for intranode transfers). +# +# Pipeline wiring (stage_id, execution_type, model_stage, input_sources, +# custom processors, async-chunk builders) lives in +# ``vllm_omni/model_executor/models/qwen3_omni/pipeline.py`` and is +# merged into the stages below by the deploy loader. +# +# Why this yaml exists: +# * On CUDA/H100 Qwen3-Omni-MoE ships via ``SharedMemoryConnector`` +# (see ``qwen3_omni_moe.yaml``). On MI300X the SHM path still works +# but XGMI RDMA can substantially reduce stage-transition latency +# for the thinker->talker hidden-state stream and the talker->code2wav +# codec-frame stream, both of which run in async-chunk mode. +# * This config swaps the connector definition to +# ``MoriTransferEngineConnector`` while keeping the pipeline topology, +# sampling params, and async_chunk=true gating identical to the CUDA +# deploy, so the chunk_transfer_adapter path (scheduler-owned +# background put/get threads) is fully exercised. +# +# Usage: +# vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --log-stats \ +# --deploy-config vllm_omni/deploy/qwen3_omni_moe_mori_intranode.yaml +# +# Dependencies: mori (``pip install mori``), torch-rocm>=2.9, vllm-omni +# with #2383 deploy schema, ibverbs/RDMA NICs exposed via /dev/infiniband. +# +# Limitation: TP=1-only. Connector raises NotImplementedError on +# tp_world_size > 1 (chunk-path endpoint derivation isn't rank-aware yet). +async_chunk: true + +connectors: + mori_connector: + name: MoriTransferEngineConnector + extra: + host: "auto" + zmq_port: 50051 + backend_type: "xgmi" # AMD Infinity Fabric GPU-to-GPU direct + device_name: "" # leave empty to honour $MORI_RDMA_DEVICES + memory_pool_size: 536870912 # 512 MB, matches Qwen2.5-Omni Mori deploy + memory_pool_device: "cuda" # GPU memory required for XGMI RDMA transfers + xgmi_num_streams: 64 + xgmi_num_events: 64 + qp_per_transfer: 1 + num_worker_threads: 1 + post_batch_size: -1 + # Chunk-transfer knobs consumed by talker2code2wav_async_chunk: + # 25 decoded codec frames per connector.put (~40 ms at 625 Hz + # codec rate) with a 25-frame left context to satisfy the + # code2wav receptive field. Kept identical to the SHM deploy so + # accuracy stays comparable when swapping backends. + codec_chunk_frames: 25 + codec_left_context_frames: 25 + +stages: + - stage_id: 0 + gpu_memory_utilization: 0.9 + enforce_eager: true # ROCm override: avoid flashinfer autotune path + devices: "0" + output_connectors: + to_stage_1: mori_connector + default_sampling_params: + temperature: 0.4 + top_p: 0.9 + top_k: 1 + max_tokens: 2048 + seed: 42 + repetition_penalty: 1.05 + + - stage_id: 1 + gpu_memory_utilization: 0.9 + enforce_eager: true + devices: "1" + input_connectors: + from_stage_0: mori_connector + output_connectors: + to_stage_2: mori_connector + default_sampling_params: + temperature: 0.9 + top_k: 50 + max_tokens: 4096 + seed: 42 + repetition_penalty: 1.05 + + - stage_id: 2 + gpu_memory_utilization: 0.3 + max_num_seqs: 1 + enforce_eager: true + async_scheduling: false + # Codec prefill length (Q * num_frames) exceeds 32k default; + # matches qwen3_omni_moe.yaml code2wav sizing. + max_num_batched_tokens: 51200 + devices: "2" + input_connectors: + from_stage_1: mori_connector + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + repetition_penalty: 1.1 diff --git a/vllm_omni/distributed/__init__.py b/vllm_omni/distributed/__init__.py index 7af13a322..3506f02f6 100644 --- a/vllm_omni/distributed/__init__.py +++ b/vllm_omni/distributed/__init__.py @@ -6,6 +6,7 @@ from .omni_connectors import ( MooncakeConnector, MooncakeStoreConnector, MooncakeTransferEngineConnector, + MoriTransferEngineConnector, OmniConnectorBase, OmniConnectorFactory, OmniTransferConfig, @@ -25,6 +26,7 @@ __all__ = [ "MooncakeConnector", # compat alias "MooncakeStoreConnector", "MooncakeTransferEngineConnector", + "MoriTransferEngineConnector", "SharedMemoryConnector", "YuanrongConnector", "YuanrongTransferEngineConnector", diff --git a/vllm_omni/distributed/omni_connectors/__init__.py b/vllm_omni/distributed/omni_connectors/__init__.py index 98e3a5c36..0cee596bb 100644 --- a/vllm_omni/distributed/omni_connectors/__init__.py +++ b/vllm_omni/distributed/omni_connectors/__init__.py @@ -17,6 +17,11 @@ try: from .connectors.mooncake_transfer_engine_connector import MooncakeTransferEngineConnector except ImportError: MooncakeTransferEngineConnector = None # RDMA deps (msgspec/zmq/mooncake) not installed + +try: + from .connectors.mori_transfer_engine_connector import MoriTransferEngineConnector +except ImportError: + MoriTransferEngineConnector = None # RDMA deps (msgspec/zmq/mori) not installed from .factory import OmniConnectorFactory from .utils.config import ConnectorSpec, OmniTransferConfig from .utils.initialization import ( @@ -44,6 +49,7 @@ __all__ = [ "MooncakeConnector", # compat alias → MooncakeStoreConnector "MooncakeStoreConnector", "MooncakeTransferEngineConnector", + "MoriTransferEngineConnector", "SharedMemoryConnector", "YuanrongConnector", "YuanrongTransferEngineConnector", diff --git a/vllm_omni/distributed/omni_connectors/connectors/mori_transfer_engine_connector.py b/vllm_omni/distributed/omni_connectors/connectors/mori_transfer_engine_connector.py new file mode 100644 index 000000000..360f4527a --- /dev/null +++ b/vllm_omni/distributed/omni_connectors/connectors/mori_transfer_engine_connector.py @@ -0,0 +1,962 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""OmniConnector backed by the Mori RDMA transfer engine. + +Implements the ``OmniConnector`` interface using Mori's ``IOEngine`` / +``MemoryDesc`` API to perform zero-copy RDMA transfers between +disaggregated prefill and decode workers. + +Notable design points: + * Remote peers must be registered via ``register_remote_engine()`` + with the peer's ``EngineDesc`` before any data transfer can proceed. + * Memory regions are described by ``MemoryDesc`` objects (serialisable + via pack/unpack) rather than raw virtual addresses. + * Transfers are dispatched through ``IOEngine.batch_write()`` and + tracked asynchronously via ``TransferStatus`` objects. + +ZMQ handshake protocol: + * **Pull request** – receiver sends ``MoriPullRequest`` (msgspec-encoded) + containing its ``EngineDesc`` and pool ``MemoryDesc`` so the sender can + register the remote engine and RDMA-write directly into the receiver's + pool at the specified offset. + * **Query request** – receiver sends ``QUERY_INFO`` prefix + ``QueryRequest`` + to check whether data is ready on the sender side (metadata-less get path). +""" + +import os +import queue +import socket +import threading +import time as _time_mod +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import msgspec +import torch +import zmq + +from ..utils.logging import get_connector_logger +from ..utils.memory_pool import BufferAllocator, ManagedBuffer +from ..utils.serialization import OmniSerializer +from .base import OmniConnectorBase + +logger = get_connector_logger(__name__) + +try: + from mori.cpp import TransferStatus # noqa: F401 + from mori.io import ( + BackendType, + EngineDesc, + IOEngine, + IOEngineConfig, + MemoryDesc, + PollCqMode, + RdmaBackendConfig, + XgmiBackendConfig, + ) +except ImportError: + IOEngine = None + +# Supported backend types for Mori. Kept as string constants so configuration +# (YAML / CLI / dict) stays transport-agnostic. ``rdma`` uses NIC-based RDMA +# (RoCE / IB, GDR-capable); ``xgmi`` uses AMD Infinity Fabric GPU-to-GPU +# direct links and therefore requires a CUDA pool. +_SUPPORTED_BACKENDS = ("rdma", "xgmi") + +_BUFFER_TTL_SECONDS = 300 + +TRANS_DONE = b"trans_done" +TRANS_ERROR = b"trans_error" +QUERY_INFO = b"query_info" +INFO_NOT_FOUND = b"info_not_found" + + +# --------------------------------------------------------------------------- +# ZMQ message types +# --------------------------------------------------------------------------- + + +class MoriPullRequest(msgspec.Struct): + """Receiver → sender: request an RDMA write into the receiver's pool.""" + + request_id: str + engine_desc_packed: bytes + mem_desc_packed: bytes + dst_offset: int + length: int + + +class QueryRequest(msgspec.Struct): + """Receiver → sender: query whether data for *request_id* is ready.""" + + request_id: str + + +class QueryResponse(msgspec.Struct): + """Sender → receiver: metadata about a ready buffer.""" + + request_id: str + data_size: int + is_fast_path: bool + + +# --------------------------------------------------------------------------- +# Connector +# --------------------------------------------------------------------------- + + +class MoriTransferEngineConnector(OmniConnectorBase): + """OmniConnector backed by the Mori RDMA IOEngine. + + Topology: 1 sender ↔ 1 receiver per key (same as + ``MooncakeTransferEngineConnector``). + """ + + supports_raw_data: bool = True + + # ------------------------------------------------------------------ init + def __init__(self, config: dict[str, Any]): + if IOEngine is None: + raise ImportError("Mori is not available. Install via: pip install mori") + + self._closed = False + self._bind_error: Exception | None = None + + # Teardown-safe defaults + self._stop_event = threading.Event() + self._sender_executor: ThreadPoolExecutor | None = None + self._listener_thread: threading.Thread | None = None + self._listener_ready = threading.Event() + self._local_buffers: dict[str, Any] = {} + self._local_buffers_lock = threading.Lock() + self._req_local = threading.local() + self._worker_local = threading.local() + self._last_ttl_check: float = _time_mod.monotonic() + + # Track remote engines already registered with IOEngine. + # ``mori.io.IOEngine.deregister_remote_engine`` requires the original + # ``EngineDesc`` object (its pybind signature is typed + # ``deregister_remote_engine(self, engine_desc: EngineDesc)``), not + # just the key string -- so we keep the desc alongside the key so + # ``close()`` can pass a valid object back at teardown. + self._registered_engines: dict[str, EngineDesc] = {} + self._registered_engines_lock = threading.Lock() + + self._metrics = { + "puts": 0, + "gets": 0, + "bytes_transferred": 0, + "errors": 0, + "timeouts": 0, + } + + self.config = config + + # Stage id is read by OmniChunkTransferAdapter (process_pending_chunks / + # _send_single_request / _poll_single_request / ...) to decide whether + # this connector sits at the sender or receiver end of a stage pair. + # Kept parallel to ``SharedMemoryConnector`` and other OmniConnector + # implementations. Populated by ``build_stage_connectors`` via the + # ``stage_id`` key that ``get_connectors_config_for_stage`` injects. + self.stage_id = config.get("stage_id", -1) + + # ---- Host / ZMQ ---- + host_cfg = config.get("host", "127.0.0.1") + if host_cfg.lower() == "auto": + self.host = self._get_local_ip() + logger.info(f"Auto-detected local IP: {self.host}") + else: + self.host = host_cfg + self.zmq_port = config.get("zmq_port", 50051) + + # ---- Backend selection ---- + # Defaults to "rdma" so existing deployments (pre-XGMI support) keep + # working unchanged. ``xgmi`` selects AMD Infinity Fabric GPU-to-GPU + # direct links; see ``mori.io.XgmiBackendConfig``. + backend_type_cfg = str(config.get("backend_type", "rdma")).lower() + if backend_type_cfg not in _SUPPORTED_BACKENDS: + raise ValueError(f"Invalid backend_type={backend_type_cfg!r}. Supported: {list(_SUPPORTED_BACKENDS)}.") + self.backend_type = backend_type_cfg + + # ---- RDMA device (RDMA backend only) ---- + self.device_name = "" + if self.backend_type == "rdma": + self.device_name = config.get("device_name", "") + if not self.device_name: + env_dev = os.environ.get("MORI_RDMA_DEVICES", "") + if env_dev: + self.device_name = env_dev + logger.info(f"Using MORI_RDMA_DEVICES from env: {self.device_name}") + elif config.get("device_name"): + logger.warning( + "device_name=%r is ignored for backend_type='xgmi' (XGMI does not use an RDMA NIC).", + config.get("device_name"), + ) + + # ---- Pool config ---- + self.pool_size = config.get("memory_pool_size", 1024**3) + self.pool_device = config.get("memory_pool_device", "cpu") + if self.backend_type == "xgmi" and self.pool_device == "cpu": + raise ValueError( + "backend_type='xgmi' requires memory_pool_device='cuda': " + "XGMI is a GPU-to-GPU fabric and cannot address CPU memory." + ) + + # ---- Sender info (receiver uses this when metadata=None) ---- + self.sender_host = config.get("sender_host", None) + self.sender_zmq_port = config.get("sender_zmq_port", None) + + # ---- Role ---- + # ``sender`` : binds the ZMQ listener, accepts ``put()``. + # ``receiver`` : no listener, only ``get()`` by querying an + # upstream sender whose endpoint lives in + # ``sender_host`` / ``sender_zmq_port``. + role = str(config.get("role", "sender")).lower() + if role not in {"sender", "receiver"}: + raise ValueError(f"Invalid role={role!r} for MoriTransferEngineConnector. Expected 'sender' or 'receiver'.") + self.role = role + self.can_put = role == "sender" + + # ---- Mori IOEngine ---- + if self.device_name: + os.environ["MORI_RDMA_DEVICES"] = self.device_name + + engine_config = IOEngineConfig(host=self.host, port=0) + self.engine_key = f"omni-{role}-{uuid.uuid4().hex[:8]}-pid{os.getpid()}-{self.host}" + self.engine = IOEngine(self.engine_key, engine_config) + + # ---- Backend creation (per backend_type) ---- + # ``IOEngine.batch_write`` is backend-agnostic, so only construction + # differs; the data-plane / ZMQ handshake paths below are identical. + if self.backend_type == "xgmi": + xgmi_cfg = XgmiBackendConfig() + xgmi_cfg.num_streams = config.get("xgmi_num_streams", 64) + xgmi_cfg.num_events = config.get("xgmi_num_events", 64) + self.engine.create_backend(BackendType.XGMI, xgmi_cfg) + logger.info(f"Mori backend: XGMI (num_streams={xgmi_cfg.num_streams}, num_events={xgmi_cfg.num_events})") + else: # rdma + qp_per_transfer = config.get("qp_per_transfer", 1) + post_batch_size = config.get("post_batch_size", -1) + num_workers = config.get("num_worker_threads", 1) + rdma_cfg = RdmaBackendConfig( + qp_per_transfer, + post_batch_size, + num_workers, + PollCqMode.POLLING, + False, + ) + self.engine.create_backend(BackendType.RDMA, rdma_cfg) + logger.info( + f"Mori backend: RDMA (qp_per_transfer={qp_per_transfer}, " + f"num_workers={num_workers}, device={self.device_name or 'auto'})" + ) + + self.engine_desc: EngineDesc = self.engine.get_engine_desc() + self.engine_desc_packed: bytes = self.engine_desc.pack() + logger.info(f"Mori IOEngine ready: key={self.engine_key} at {self.engine_desc.host}:{self.engine_desc.port}") + + # ---- Pool allocation & Mori memory registration ---- + logger.info( + f"Allocating {self.backend_type.upper()} pool: {self.pool_size / 1024**2:.2f} MB on {self.pool_device}" + ) + try: + if self.pool_device == "cpu": + self.pool = torch.empty(self.pool_size, dtype=torch.uint8).pin_memory() + else: + self.pool = torch.empty( + self.pool_size, + dtype=torch.uint8, + device=self.pool_device, + ) + self.pool_mem_desc: MemoryDesc = self.engine.register_torch_tensor(self.pool) + self.pool_mem_desc_packed: bytes = self.pool_mem_desc.pack() + self.base_ptr = self.pool.data_ptr() + except Exception as e: + logger.error(f"Failed to allocate/register pool: {e}") + raise + + self.allocator = BufferAllocator(self.pool_size, alignment=4096) + + # ---- ZMQ / threading ---- + self.zmq_ctx = zmq.Context() + self._sender_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="mori-sender") + + logger.info( + f"MoriTransferEngineConnector config:\n" + f" Local: host={self.host}, zmq_port={self.zmq_port}\n" + f" Remote: sender_host={self.sender_host}, " + f"sender_zmq_port={self.sender_zmq_port}\n" + f" Role: can_put={self.can_put}, " + f"configured_role={config.get('role', 'sender')}" + ) + + if self.can_put: + self._last_ttl_check = _time_mod.monotonic() + self._listener_thread = threading.Thread(target=self._zmq_listener_loop, daemon=True) + self._listener_thread.start() + self._listener_ready.wait(timeout=1.0) + if self._bind_error is not None: + raise RuntimeError( + f"MoriTransferEngineConnector failed to bind ZMQ on {self.host}:{self.zmq_port}: {self._bind_error}" + ) from self._bind_error + logger.info(f"MoriTransferEngineConnector SENDER ready (ZMQ on {self.host}:{self.zmq_port})") + else: + if not self.sender_host or self.sender_host.lower() == "auto": + logger.info("MoriTransferEngineConnector RECEIVER: awaiting sender info via update_sender_info().") + else: + logger.info( + f"MoriTransferEngineConnector RECEIVER ready (sender at {self.sender_host}:{self.sender_zmq_port})" + ) + + # -------------------------------------------------------- public helpers + def get_connection_info(self) -> dict[str, Any]: + return { + "host": self.host, + "zmq_port": self.zmq_port, + "engine_key": self.engine_key, + "can_put": self.can_put, + } + + def update_sender_info(self, sender_host: str, sender_zmq_port: int) -> None: + """Inject the sender's ZMQ endpoint into the receiver connector.""" + self.sender_host = sender_host + self.sender_zmq_port = sender_zmq_port + logger.info(f"Sender info updated: host={sender_host!r}, zmq_port={sender_zmq_port}") + + # -------------------------------------------------- internal helpers + @staticmethod + def _get_local_ip() -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + try: + return socket.gethostbyname(socket.gethostname()) + except Exception: + return "127.0.0.1" + + def _get_req_socket(self, zmq_addr: str, timeout_ms: int) -> zmq.Socket: + cache: dict[str, zmq.Socket] | None = getattr(self._req_local, "cache", None) + if cache is None: + cache = {} + self._req_local.cache = cache + + sock = cache.get(zmq_addr) + if sock is None: + sock = self.zmq_ctx.socket(zmq.REQ) + sock.connect(zmq_addr) + cache[zmq_addr] = sock + sock.setsockopt(zmq.SNDTIMEO, timeout_ms) + sock.setsockopt(zmq.RCVTIMEO, timeout_ms) + return sock + + def _invalidate_req_socket(self, zmq_addr: str) -> None: + cache: dict[str, zmq.Socket] | None = getattr(self._req_local, "cache", None) + if cache is None: + return + sock = cache.pop(zmq_addr, None) + if sock is not None: + try: + sock.close(linger=0) + except Exception: + pass + + def _ensure_remote_registered(self, engine_desc_packed: bytes) -> str: + """Register a remote engine with the local IOEngine (idempotent).""" + desc = EngineDesc.unpack(engine_desc_packed) + key = desc.key + with self._registered_engines_lock: + if key not in self._registered_engines: + self.engine.register_remote_engine(desc) + self._registered_engines[key] = desc + logger.debug(f"Registered remote Mori engine: {key}") + return key + + # ----------------------------------------------------------------- put() + def put( + self, + from_stage: str, + to_stage: str, + put_key: str, + data: Any, + ) -> tuple[bool, int, dict[str, Any] | None]: + if self._closed: + raise RuntimeError("Cannot put: MoriTransferEngineConnector is closed") + if not self.can_put: + logger.warning(f"Rejecting put for {put_key}: connector is receiver-only") + return False, 0, None + + put_key = self._make_key(put_key, from_stage, to_stage) + try: + src_offset = 0 + size = 0 + holder = None + is_fast_path = True + should_release = False + + # Reject empty payloads early + if isinstance(data, bytes) and len(data) == 0: + return False, 0, None + if isinstance(data, torch.Tensor) and data.nbytes == 0: + return False, 0, None + + # Serialize non-raw types + if not isinstance(data, (ManagedBuffer, torch.Tensor, bytes)): + data = OmniSerializer.serialize(data) + is_fast_path = False + + if isinstance(data, ManagedBuffer): + if data.pool_tensor.data_ptr() != self.pool.data_ptr(): + data = data.tensor.contiguous() + else: + src_offset = data.offset + size = data.size + holder = data + should_release = False + + if isinstance(data, (torch.Tensor, bytes)): + if isinstance(data, torch.Tensor): + size = data.nbytes + tensor_data = data + else: + size = len(data) + tensor_data = torch.frombuffer(data, dtype=torch.uint8) + + try: + offset = self.allocator.alloc(size) + holder = ManagedBuffer(self.allocator, offset, size, self.pool) + should_release = True + except MemoryError: + logger.error(f"Pool exhausted for {size} bytes") + return False, 0, None + + try: + dst_t = holder.tensor + if isinstance(data, torch.Tensor): + if not data.is_contiguous(): + data = data.contiguous() + src_view = data.view(torch.uint8).flatten() + if src_view.device != dst_t.device: + dst_t.copy_(src_view, non_blocking=True) + if src_view.is_cuda: + with torch.cuda.device(src_view.device): + torch.cuda.current_stream().synchronize() + elif dst_t.is_cuda: + with torch.cuda.device(dst_t.device): + torch.cuda.current_stream().synchronize() + else: + dst_t.copy_(src_view) + if dst_t.is_cuda: + with torch.cuda.device(dst_t.device): + torch.cuda.current_stream().synchronize() + else: + dst_t.copy_(tensor_data) + if dst_t.is_cuda: + with torch.cuda.device(dst_t.device): + torch.cuda.current_stream().synchronize() + except Exception as e: + holder.release() + logger.error(f"Failed to copy data to pool: {e}") + return False, 0, None + + src_offset = offset + + if size <= 0: + if should_release and isinstance(holder, ManagedBuffer): + holder.release() + return False, 0, None + + with self._local_buffers_lock: + old = self._local_buffers.pop(put_key, None) + if old: + _, _, oh, osr, _, _ = old + if osr and isinstance(oh, ManagedBuffer): + oh.release() + logger.warning(f"Released stale buffer for duplicate key: {put_key}") + # (offset, size, holder, should_release, is_fast_path, ts) + self._local_buffers[put_key] = ( + src_offset, + size, + holder, + should_release, + is_fast_path, + _time_mod.monotonic(), + ) + + metadata = { + "source_host": self.host, + "source_port": self.zmq_port, + "data_size": size, + "is_fast_path": is_fast_path, + } + self._metrics["puts"] += 1 + self._metrics["bytes_transferred"] += size + return True, size, metadata + + except Exception as e: + self._metrics["errors"] += 1 + logger.error(f"Put failed for {put_key}: {e}", exc_info=True) + return False, 0, None + + # ------------------------------------------------ query (no-metadata get) + def _query_metadata_from_sender(self, get_key: str) -> dict[str, Any] | None: + zmq_addr = f"tcp://{self.sender_host}:{self.sender_zmq_port}" + req_socket = self._get_req_socket(zmq_addr, timeout_ms=5000) + try: + q = QueryRequest(request_id=get_key) + req_socket.send(QUERY_INFO + msgspec.msgpack.encode(q)) + resp = req_socket.recv() + if resp == INFO_NOT_FOUND: + return None + qr = msgspec.msgpack.decode(resp, type=QueryResponse) + return { + "source_host": self.sender_host, + "source_port": self.sender_zmq_port, + "data_size": qr.data_size, + "is_fast_path": qr.is_fast_path, + } + except Exception as e: + self._invalidate_req_socket(zmq_addr) + logger.debug(f"Metadata query failed for {get_key}: {e}") + return None + + # ----------------------------------------------------------------- get() + def get( + self, + from_stage: str, + to_stage: str, + get_key: str, + metadata: dict[str, Any] | None = None, + ) -> tuple[Any, int] | None: + if self._closed: + raise RuntimeError("Cannot get: MoriTransferEngineConnector is closed") + + get_key = self._make_key(get_key, from_stage, to_stage) + _t0 = _time_mod.perf_counter() + + # Resolve metadata + if not metadata: + if not self.sender_host or not self.sender_zmq_port or str(self.sender_host).lower() == "auto": + raise RuntimeError("get(metadata=None) requires sender info. Call update_sender_info() first.") + metadata = self._query_metadata_from_sender(get_key) + if not metadata: + return None + + _t1 = _time_mod.perf_counter() + _query_ms = (_t1 - _t0) * 1000 + + src_host = metadata.get("source_host") + src_port = metadata.get("source_port") + data_size = metadata.get("data_size", 0) + is_fast_path = metadata.get("is_fast_path", False) + + if not src_host or not src_port or str(src_host).lower() == "auto": + logger.error(f"Invalid metadata for {get_key}") + return None + if data_size == 0: + logger.warning(f"Skipping get for {get_key}: data_size is 0") + return None + + # Allocate destination buffer + try: + offset = self.allocator.alloc(data_size) + recv_buf = ManagedBuffer(self.allocator, offset, data_size, self.pool) + except MemoryError: + logger.error(f"Failed to allocate {data_size} bytes for get") + return None + + _t2 = _time_mod.perf_counter() + _alloc_ms = (_t2 - _t1) * 1000 + + # Build pull request with Mori-specific EngineDesc + MemoryDesc + pull_req = MoriPullRequest( + request_id=get_key, + engine_desc_packed=self.engine_desc_packed, + mem_desc_packed=self.pool_mem_desc_packed, + dst_offset=offset, + length=data_size, + ) + + _base_timeout_ms = 30000 + _size_timeout_ms = max(0, (data_size // (100 * 1024 * 1024))) * 5000 + _total_timeout_ms = _base_timeout_ms + _size_timeout_ms + zmq_addr = f"tcp://{src_host}:{src_port}" + req_socket = self._get_req_socket(zmq_addr, timeout_ms=_total_timeout_ms) + + try: + req_socket.send(msgspec.msgpack.encode(pull_req)) + resp = req_socket.recv() + + _t3 = _time_mod.perf_counter() + _rdma_ms = (_t3 - _t2) * 1000 + + if resp == TRANS_DONE: + if self.pool.is_cuda: + with torch.cuda.device(self.pool.device): + torch.cuda.current_stream().synchronize() + + _t4 = _time_mod.perf_counter() + _sync_ms = (_t4 - _t3) * 1000 + + if is_fast_path: + _total_ms = (_time_mod.perf_counter() - _t0) * 1000 + _mbps = (data_size / 1024 / 1024) / (_total_ms / 1000) if _total_ms > 0 else 0 + logger.info( + f"[MORI GET] {get_key}: query={_query_ms:.1f}ms, " + f"alloc={_alloc_ms:.1f}ms, rdma={_rdma_ms:.1f}ms, " + f"sync={_sync_ms:.1f}ms, total={_total_ms:.1f}ms, " + f"{_mbps:.1f} MB/s (fast_path)" + ) + self._metrics["gets"] += 1 + self._metrics["bytes_transferred"] += data_size + return recv_buf, data_size + else: + try: + raw = recv_buf.to_bytes() + val = OmniSerializer.deserialize(raw) + _total_ms = (_time_mod.perf_counter() - _t0) * 1000 + _mbps = (data_size / 1024 / 1024) / (_total_ms / 1000) if _total_ms > 0 else 0 + logger.info(f"[MORI GET] {get_key}: total={_total_ms:.1f}ms, {_mbps:.1f} MB/s (deserialized)") + self._metrics["gets"] += 1 + self._metrics["bytes_transferred"] += data_size + return val, data_size + finally: + recv_buf.release() + else: + self._metrics["errors"] += 1 + logger.error(f"MORI get failed: received {resp} instead of TRANS_DONE") + recv_buf.release() + return None + except Exception as e: + self._invalidate_req_socket(zmq_addr) + self._metrics["timeouts"] += 1 + logger.error(f"MORI get error: {e}", exc_info=True) + recv_buf.release() + return None + + # ------------------------------------------------------------- cleanup() + def cleanup( + self, + request_id: str, + from_stage: str | None = None, + to_stage: str | None = None, + ) -> None: + if (from_stage is None) != (to_stage is None): + raise ValueError("cleanup() requires both from_stage and to_stage, or neither.") + if from_stage is not None and to_stage is not None: + request_id = self._make_key(request_id, from_stage, to_stage) + with self._local_buffers_lock: + item = self._local_buffers.pop(request_id, None) + if item: + _, _, holder, sr, _, _ = item + if sr and isinstance(holder, ManagedBuffer): + holder.release() + + # -------------------------------------------------------------- health() + def health(self) -> dict[str, Any]: + if self._closed: + return {"status": "unhealthy", "error": "Connector is closed"} + return { + "status": "healthy", + "host": self.host, + "pool_device": self.pool_device, + "pool_size": self.pool_size, + "engine_key": self.engine_key, + **self._metrics, + } + + # --------------------------------------------------------------- close() + def close(self) -> None: + if getattr(self, "_closed", True): + return + self._closed = True + logger.info("Closing MoriTransferEngineConnector...") + + self._stop_event.set() + + if self._listener_thread is not None and self._listener_thread.is_alive(): + self._listener_thread.join(timeout=2.0) + if self._listener_thread.is_alive(): + logger.warning("Listener thread did not stop gracefully") + + if self._sender_executor is not None: + self._sender_executor.shutdown(wait=True, cancel_futures=False) + + with self._local_buffers_lock: + for _k, item in list(self._local_buffers.items()): + _, _, holder, sr, _, _ = item + if sr and isinstance(holder, ManagedBuffer): + holder.release() + self._local_buffers.clear() + + cache: dict[str, zmq.Socket] | None = getattr(self._req_local, "cache", None) + if cache: + for _addr, sock in cache.items(): + try: + sock.close(linger=0) + except Exception: + pass + cache.clear() + + try: + if hasattr(self, "zmq_ctx"): + self.zmq_ctx.term() + except Exception as e: + logger.warning(f"Failed to terminate ZMQ context: {e}") + + # ---- Release Mori IOEngine resources ---- + # ``mori.io.IOEngine`` does not expose an explicit ``shutdown()`` / + # ``close()`` entry-point, so we best-effort deregister every resource + # we registered with it and then drop the only reference we hold so + # Python GC triggers the C++ destructor. + engine = getattr(self, "engine", None) + if engine is not None: + pool_desc = getattr(self, "pool_mem_desc", None) + if pool_desc is not None: + try: + engine.deregister_memory(pool_desc) + except Exception as e: + logger.warning(f"Mori deregister_memory failed: {e}") + self.pool_mem_desc = None # type: ignore[assignment] + + with self._registered_engines_lock: + remote_descs = list(self._registered_engines.values()) + self._registered_engines.clear() + for desc in remote_descs: + try: + engine.deregister_remote_engine(desc) + except Exception as e: + logger.debug(f"Mori deregister_remote_engine({desc.key!r}) failed: {e}") + + # Drop the only reference to IOEngine; the C++ destructor runs + # under Python GC and unwinds backend / RDMA fabric state. + self.engine = None # type: ignore[assignment] + + self.pool = None # type: ignore[assignment] + logger.info("MoriTransferEngineConnector closed.") + + # ============================================================== LISTENER + def _cleanup_stale_buffers(self) -> None: + """Reclaim buffers older than ``_BUFFER_TTL_SECONDS``. + + Prevents permanent memory leaks when a receiver crashes or times out + without ever pulling the data. + + TODO(zejwang): In extreme rare case, long transfer time, there might + exist TTL cleanup vs in-flight RDMA transfer conflict, which will be + handled in a follow-up PR. Same race is acknowledged in + ``MooncakeTransferEngineConnector._cleanup_stale_buffers``. + """ + now = _time_mod.monotonic() + with self._local_buffers_lock: + stale = [k for k, v in self._local_buffers.items() if now - v[5] > _BUFFER_TTL_SECONDS] + for k in stale: + item = self._local_buffers.pop(k) + _, _, holder, sr, _, _ = item + if sr and isinstance(holder, ManagedBuffer): + holder.release() + logger.warning(f"TTL expired ({_BUFFER_TTL_SECONDS}s): reclaimed buffer for {k}") + + def _zmq_listener_loop(self) -> None: + sock = self.zmq_ctx.socket(zmq.ROUTER) + + try: + sock.bind(f"tcp://{self.host}:{self.zmq_port}") + except zmq.ZMQError as exc: + # Any bind failure (EADDRINUSE, EADDRNOTAVAIL, EACCES, etc.) is + # fatal for a sender — fail fast so __init__ propagates the error. + # There is no silent receiver fallback; roles are explicitly + # assigned (matches MooncakeTransferEngineConnector). + logger.error(f"ZMQ bind failed on {self.host}:{self.zmq_port}: {exc} (errno={exc.errno})") + self.can_put = False + self._bind_error = exc + self._listener_ready.set() + return + + self._listener_ready.set() + + notify_addr = f"inproc://mori-notify-{id(self)}" + notify_recv = self.zmq_ctx.socket(zmq.PULL) + notify_recv.bind(notify_addr) + + poller = zmq.Poller() + poller.register(sock, zmq.POLLIN) + poller.register(notify_recv, zmq.POLLIN) + + response_queue: queue.Queue = queue.Queue() + + try: + while not self._stop_event.is_set(): + try: + events = dict(poller.poll(1000)) + + if notify_recv in events: + while True: + try: + notify_recv.recv(zmq.NOBLOCK) + except zmq.Again: + break + + while True: + try: + identity, response = response_queue.get_nowait() + sock.send_multipart([identity, b"", response]) + except queue.Empty: + break + + now = _time_mod.monotonic() + if now - self._last_ttl_check >= 10.0: + self._last_ttl_check = now + self._cleanup_stale_buffers() + + if sock in events: + frames = sock.recv_multipart() + if len(frames) >= 2: + self._sender_executor.submit( + self._handle_pull_request, + response_queue, + notify_addr, + frames[0], + frames[-1], + ) + except zmq.ContextTerminated: + break + except Exception: + logger.debug("Listener loop error", exc_info=True) + finally: + try: + notify_recv.close(linger=0) + sock.close(linger=0) + except Exception: + pass + + def _handle_pull_request( + self, + response_queue: queue.Queue, + notify_addr: str, + identity: bytes, + payload: bytes, + ) -> None: + try: + if payload.startswith(QUERY_INFO): + self._handle_query_request( + response_queue, + notify_addr, + identity, + payload[len(QUERY_INFO) :], + ) + return + + pull = msgspec.msgpack.decode(payload, type=MoriPullRequest) + + with self._local_buffers_lock: + item = self._local_buffers.get(pull.request_id) + + if not item: + response_queue.put((identity, TRANS_ERROR)) + self._notify_listener(notify_addr) + return + + src_offset, src_size, _, _, _, _ = item + + # Validate against sender's own src_size before RDMA write: + # length < src_size silently truncates the payload; length > + # src_size reads past this allocation into adjacent in-flight + # buffers because pool_mem_desc covers the whole pool, not just + # this slot, so Mori does not bound-check. Refuse on mismatch + # rather than corrupt or leak data silently. + if pull.length != src_size: + logger.error( + "Length mismatch for %s: sender src_size=%d != receiver length=%d; refusing transfer", + pull.request_id, + src_size, + pull.length, + ) + response_queue.put((identity, TRANS_ERROR)) + self._notify_listener(notify_addr) + return + + # Register the receiver's IOEngine (idempotent) + self._ensure_remote_registered(pull.engine_desc_packed) + + # Reconstruct the receiver's pool MemoryDesc + remote_mem = MemoryDesc.unpack(pull.mem_desc_packed) + + # RDMA write from local pool → remote pool. Use src_size (sender's + # own record); it has just been validated to equal pull.length. + transfer_uid = self.engine.allocate_transfer_uid() + statuses = self.engine.batch_write( + [self.pool_mem_desc], + [[src_offset]], + [remote_mem], + [[pull.dst_offset]], + [[src_size]], + [transfer_uid], + ) + + success = True + for st in statuses: + st.Wait() + if st.Failed(): + logger.error(f"RDMA write failed for {pull.request_id}: {st.Message()}") + success = False + + if success: + self.cleanup(pull.request_id) + response_queue.put((identity, TRANS_DONE)) + else: + logger.warning(f"RDMA write failed for {pull.request_id}. Buffer retained for retry.") + response_queue.put((identity, TRANS_ERROR)) + + except Exception as e: + logger.error(f"Pull request handler error: {e}", exc_info=True) + response_queue.put((identity, TRANS_ERROR)) + + self._notify_listener(notify_addr) + + def _handle_query_request( + self, + response_queue: queue.Queue, + notify_addr: str, + identity: bytes, + payload: bytes, + ) -> None: + try: + q = msgspec.msgpack.decode(payload, type=QueryRequest) + with self._local_buffers_lock: + item = self._local_buffers.get(q.request_id) + if not item: + response_queue.put((identity, INFO_NOT_FOUND)) + else: + _, data_size, _, _, is_fast, _ = item + resp = QueryResponse( + request_id=q.request_id, + data_size=data_size, + is_fast_path=is_fast, + ) + response_queue.put((identity, msgspec.msgpack.encode(resp))) + except Exception as e: + logger.error(f"Query handler error: {e}") + response_queue.put((identity, INFO_NOT_FOUND)) + + self._notify_listener(notify_addr) + + def _notify_listener(self, notify_addr: str) -> None: + try: + local = self._worker_local + sock = getattr(local, "notify_socket", None) + cached_addr = getattr(local, "notify_addr", None) + if sock is None or cached_addr != notify_addr: + if sock is not None: + sock.close(linger=0) + sock = self.zmq_ctx.socket(zmq.PUSH) + sock.connect(notify_addr) + local.notify_socket = sock + local.notify_addr = notify_addr + sock.send(b"", zmq.NOBLOCK) + except Exception: + local.notify_socket = None + local.notify_addr = None diff --git a/vllm_omni/distributed/omni_connectors/factory.py b/vllm_omni/distributed/omni_connectors/factory.py index 7ee2dcde3..c49da1c6c 100644 --- a/vllm_omni/distributed/omni_connectors/factory.py +++ b/vllm_omni/distributed/omni_connectors/factory.py @@ -114,11 +114,23 @@ def _create_mooncake_transfer_engine_connector(config: dict[str, Any]) -> OmniCo return MooncakeTransferEngineConnector(config) +def _create_mori_transfer_engine_connector(config: dict[str, Any]) -> OmniConnectorBase: + try: + from .connectors.mori_transfer_engine_connector import MoriTransferEngineConnector + except ImportError: + import sys + + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + from omni_connectors.connectors.mori_transfer_engine_connector import MoriTransferEngineConnector + return MoriTransferEngineConnector(config) + + # Register connectors OmniConnectorFactory.register_connector("MooncakeStoreConnector", _create_mooncake_store_connector) OmniConnectorFactory.register_connector("MooncakeTransferEngineConnector", _create_mooncake_transfer_engine_connector) OmniConnectorFactory.register_connector("SharedMemoryConnector", _create_shm_connector) OmniConnectorFactory.register_connector("YuanrongConnector", _create_yuanrong_connector) OmniConnectorFactory.register_connector("YuanrongTransferEngineConnector", _create_yuanrong_transfer_engine_connector) +OmniConnectorFactory.register_connector("MoriTransferEngineConnector", _create_mori_transfer_engine_connector) # Backward-compatible aliases – will be removed in the future OmniConnectorFactory.register_connector("MooncakeConnector", _create_mooncake_store_connector) diff --git a/vllm_omni/distributed/omni_connectors/utils/config.py b/vllm_omni/distributed/omni_connectors/utils/config.py index abe6e6a20..ee049904f 100644 --- a/vllm_omni/distributed/omni_connectors/utils/config.py +++ b/vllm_omni/distributed/omni_connectors/utils/config.py @@ -11,6 +11,7 @@ logger = get_connector_logger(__name__) TRANSFER_ENGINE_CONNECTOR_NAMES = frozenset( { "MooncakeTransferEngineConnector", + "MoriTransferEngineConnector", "YuanrongTransferEngineConnector", } ) diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index da1497c0c..05da3d3c3 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -1363,6 +1363,7 @@ class AsyncOmniEngine: async def _run_orchestrator() -> None: self._initialize_stages(stage_init_timeout) + pd_config = self._detect_pd_config() coordinator_pub_address: str | None = None load_balancer_factory: Callable[[], LoadBalancer] | None = None From 95f633f8307ecd8ed6dc4e5417452659541ae9b8 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:48:35 -0700 Subject: [PATCH 43/75] [skip ci] fix(config): pin VoxCPM2 KV cache to avoid OOM on small GPUs (#4279) Signed-off-by: Yueqian Lin --- vllm_omni/deploy/voxcpm2.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/vllm_omni/deploy/voxcpm2.yaml b/vllm_omni/deploy/voxcpm2.yaml index 4e3f7f3f6..5d366521b 100644 --- a/vllm_omni/deploy/voxcpm2.yaml +++ b/vllm_omni/deploy/voxcpm2.yaml @@ -1,5 +1,6 @@ # VoxCPM2 deploy: single-stage AR pipeline with per-request state batching. -# Verified on 1x H20 141GB. +# Runs on large (H20 141GB) and small (24GB L4) cards alike: the KV cache is +# pinned to a right-sized budget instead of ballooning to fill the card. # # VoxCPM2-local runtime knobs are passed through hf_overrides. async_chunk: false @@ -9,6 +10,16 @@ stages: - stage_id: 0 max_num_seqs: 32 gpu_memory_utilization: 0.9 + # Right-size the KV cache instead of letting it fill gpu_memory_utilization. + # max_num_seqs(32) * max_model_len(4096) ~= 131k tokens; 6 GiB holds ~175k + # (42x concurrency, above the 32-seq scheduler cap, so no throughput loss). + # The remaining VRAM stays free for the VoxCPM2 diffusion side-path + # (LocDiT / AudioVAE / code2wav + batch CUDA-graph pools), which allocates + # outside vLLM's KV accounting during inference. Keeps peak ~13 GiB on any + # card and avoids per-rebase OOM tuning on small CI GPUs. + # Assumes prefix caching off and max_num_seqs*max_model_len <= ~175k tokens; + # raise this cap if either grows or enable_prefix_caching is turned on. + kv_cache_memory_bytes: 6442450944 # 6 GiB enforce_eager: true trust_remote_code: true enable_prefix_caching: false From 73df8326d76fe3bba0b7b5a6abf6ad68976f24e8 Mon Sep 17 00:00:00 2001 From: longguo <107740309+abinggo@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:00:23 +0800 Subject: [PATCH 44/75] [Bugfix] purge chunk-transfer zombies on every schedule tick to keep engine-core alive on aborts (fixes #3736) (#3774) Signed-off-by: abinggo <107740309+abinggo@users.noreply.github.com> Co-authored-by: npuichigo <11533479+npuichigo@users.noreply.github.com> --- ...mni_generation_scheduler_update_session.py | 298 ++++++++++++++++++ ...st_omni_scheduler_finish_requests_purge.py | 158 ++++++++++ ...mni_scheduler_input_coordinator_cleanup.py | 3 + .../test_chunk_transfer_adapter.py | 168 +++++++++- vllm_omni/core/sched/omni_ar_scheduler.py | 31 +- .../core/sched/omni_generation_scheduler.py | 20 +- vllm_omni/core/sched/omni_scheduler_mixin.py | 120 +++++++ .../chunk_transfer_adapter.py | 64 ++++ 8 files changed, 858 insertions(+), 4 deletions(-) create mode 100644 tests/core/sched/test_omni_scheduler_finish_requests_purge.py diff --git a/tests/core/sched/test_omni_generation_scheduler_update_session.py b/tests/core/sched/test_omni_generation_scheduler_update_session.py index cf492e7cc..d94996014 100644 --- a/tests/core/sched/test_omni_generation_scheduler_update_session.py +++ b/tests/core/sched/test_omni_generation_scheduler_update_session.py @@ -20,6 +20,7 @@ from vllm.sampling_params import SamplingParams from vllm.v1.engine import EngineCoreEventType from vllm.v1.request import Request, RequestStatus, StreamingUpdate from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler +from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin # isort: on @@ -142,3 +143,300 @@ class TestReplaceSessionWithStreamingUpdate: assert session.events assert session.events[-1].type == EngineCoreEventType.QUEUED + + +class _RealignSchedulerStub(OmniSchedulerMixin): + """Minimal scheduler surface for ``_realign_request_status_to_queues``. + + Pins ``self.requests`` / ``self.running`` / ``self.waiting`` to the + same shapes the upstream ``Scheduler.finish_requests`` reads, so the + helper sees realistic state without spinning a real scheduler. + """ + + def __init__( + self, + *, + requests: dict, + running: list, + waiting: list, + ) -> None: + self.requests = requests + self.running = running + self.waiting = waiting + + +class TestRealignRequestStatusToQueues: + """Regression for the residual hang described in + https://github.com/vllm-project/vllm-omni/pull/3774 -- chunk-transfer + adapter's ``requests_origin_status`` table goes stale on the + ``waiting → running`` admit transition, and an abort that lands + before the next deque round-trip stomps stale ``WAITING`` onto a + request that actually lives in ``self.running``. After + ``max_num_seqs`` such aborts every ``input_batch`` slot is leaked and + new requests hang at ``chunks=0``. + """ + + def test_running_with_stale_waiting_status_is_realigned_to_running( + self, + ) -> None: + """The exact race the hang reproduces: a request lives in + ``self.running`` but its ``status`` is ``WAITING``. After + realign, status must be ``RUNNING`` so upstream + ``Scheduler.finish_requests`` removes it from ``self.running``. + """ + req = _make_request(request_id="req-stale") + req.status = RequestStatus.WAITING # stale -- actually in running + + sched = _RealignSchedulerStub( + requests={req.request_id: req}, + running=[req], + waiting=[], + ) + sched._realign_request_status_to_queues([req.request_id]) + + assert req.status == RequestStatus.RUNNING + + def test_waiting_with_stale_running_status_is_realigned_to_waiting( + self, + ) -> None: + """Symmetric case: request lives in ``self.waiting`` but status + is ``RUNNING``. Upstream's else branch should run, so realign to + ``WAITING``. + """ + req = _make_request(request_id="req-stale-2") + req.status = RequestStatus.RUNNING # stale -- actually in waiting + + sched = _RealignSchedulerStub( + requests={req.request_id: req}, + running=[], + waiting=[req], + ) + sched._realign_request_status_to_queues([req.request_id]) + + assert req.status == RequestStatus.WAITING + + def test_already_aligned_status_is_left_unchanged(self) -> None: + """Healthy case: status matches actual queue. No status mutation + means no spurious side effects. + """ + req_running = _make_request(request_id="req-r") + req_running.status = RequestStatus.RUNNING + req_waiting = _make_request(request_id="req-w") + req_waiting.status = RequestStatus.WAITING + + sched = _RealignSchedulerStub( + requests={ + req_running.request_id: req_running, + req_waiting.request_id: req_waiting, + }, + running=[req_running], + waiting=[req_waiting], + ) + sched._realign_request_status_to_queues([req_running.request_id, req_waiting.request_id]) + + assert req_running.status == RequestStatus.RUNNING + assert req_waiting.status == RequestStatus.WAITING + + def test_unknown_request_id_is_skipped_silently(self) -> None: + """Aligning ids that aren't in ``self.requests`` (already freed + upstream, or never existed) must be a no-op. + """ + sched = _RealignSchedulerStub(requests={}, running=[], waiting=[]) + sched._realign_request_status_to_queues(["never-existed"]) # no raise + + def test_request_in_neither_queue_is_left_unchanged(self) -> None: + """If a tracked request is in neither queue (e.g. parked in a + chunk-transfer deque), realign must not invent a status. The + adapter / deque purge owns that surface; the realign helper is + only for the admit-transition staleness. + """ + req = _make_request(request_id="req-parked") + req.status = RequestStatus.WAITING_FOR_CHUNK + + sched = _RealignSchedulerStub( + requests={req.request_id: req}, + running=[], + waiting=[], + ) + sched._realign_request_status_to_queues([req.request_id]) + + assert req.status == RequestStatus.WAITING_FOR_CHUNK + + def test_request_ids_str_is_treated_as_single_id(self) -> None: + """Match upstream ``Scheduler.finish_requests`` resolution: a + bare string is treated as one id, not iterated as characters. + """ + req = _make_request(request_id="req-s") + req.status = RequestStatus.WAITING + + sched = _RealignSchedulerStub( + requests={req.request_id: req}, + running=[req], + waiting=[], + ) + sched._realign_request_status_to_queues(req.request_id) + + assert req.status == RequestStatus.RUNNING + + def test_request_ids_none_aligns_every_known_request(self) -> None: + """``request_ids=None`` matches upstream's "all requests" path. + Realign must walk every entry in ``self.requests`` and fix any + stale status it finds. + """ + stale = _make_request(request_id="req-stale-none") + stale.status = RequestStatus.WAITING # but actually in running + + clean = _make_request(request_id="req-clean-none") + clean.status = RequestStatus.RUNNING + + sched = _RealignSchedulerStub( + requests={ + stale.request_id: stale, + clean.request_id: clean, + }, + running=[stale, clean], + waiting=[], + ) + sched._realign_request_status_to_queues(None) + + assert stale.status == RequestStatus.RUNNING + assert clean.status == RequestStatus.RUNNING + + def test_finished_request_is_skipped(self) -> None: + """Already-finished requests must not be touched -- they may + have legitimate finished statuses (FINISHED_STOPPED etc.) that + a status flip would corrupt. + """ + req = _make_request(request_id="req-finished") + req.status = RequestStatus.FINISHED_STOPPED + + sched = _RealignSchedulerStub( + requests={req.request_id: req}, + running=[req], # not realistic, but we want to prove the guard fires + waiting=[], + ) + sched._realign_request_status_to_queues([req.request_id]) + + assert req.status == RequestStatus.FINISHED_STOPPED + + +class TestPurgeFinishedFromRunning: + """Regression for the residual ``self.running`` slot leak surface + paired with ``_realign_request_status_to_queues``: even after + realign + ``super().finish_requests`` runs, corner cases can leave + already-finished or untracked entries in ``self.running`` -- e.g. + a connector cleanup that pops from ``self.requests`` without + unwinding ``self.running``, or a ``status`` mid-transition when + finish ran. The defensive post-finish purge sweeps those residues + so the worker's ``input_batch`` slot never pins a freed request. + + See https://github.com/vllm-project/vllm-omni/pull/3774 discussion + and the residual-hang reproduction in that PR. + """ + + def test_finished_request_is_purged_from_running(self) -> None: + """``is_finished()`` request lingering in ``self.running`` must + be swept so its ``input_batch`` slot is freed.""" + finished = _make_request(request_id="req-finished") + finished.status = RequestStatus.FINISHED_STOPPED # is_finished() True + + sched = _RealignSchedulerStub( + requests={finished.request_id: finished}, + running=[finished], + waiting=[], + ) + sched._purge_finished_from_running() + + assert sched.running == [] + + def test_untracked_request_is_purged_from_running(self) -> None: + """Request lingering in ``self.running`` but no longer present + in ``self.requests`` (already freed by upstream / connector) + must be swept.""" + untracked = _make_request(request_id="req-untracked") + untracked.status = RequestStatus.RUNNING + + sched = _RealignSchedulerStub( + requests={}, # already deleted from self.requests + running=[untracked], + waiting=[], + ) + sched._purge_finished_from_running() + + assert sched.running == [] + + def test_healthy_running_is_left_unchanged(self) -> None: + """Live tracked running requests must not be touched -- the + purge is defensive, not aggressive.""" + alive = _make_request(request_id="req-alive") + alive.status = RequestStatus.RUNNING + + sched = _RealignSchedulerStub( + requests={alive.request_id: alive}, + running=[alive], + waiting=[], + ) + sched._purge_finished_from_running() + + assert sched.running == [alive] + + def test_empty_running_is_noop(self) -> None: + """Empty ``self.running`` must short-circuit cleanly.""" + sched = _RealignSchedulerStub(requests={}, running=[], waiting=[]) + sched._purge_finished_from_running() + + assert sched.running == [] + + def test_mixed_alive_and_dead_keeps_only_alive_in_order(self) -> None: + """Mixed ``self.running`` -- some alive, some finished, some + untracked. Sweep keeps only the alive ones and preserves their + relative order.""" + alive_a = _make_request(request_id="req-alive-a") + alive_a.status = RequestStatus.RUNNING + finished_b = _make_request(request_id="req-finished-b") + finished_b.status = RequestStatus.FINISHED_STOPPED + untracked_c = _make_request(request_id="req-untracked-c") + untracked_c.status = RequestStatus.RUNNING + alive_d = _make_request(request_id="req-alive-d") + alive_d.status = RequestStatus.RUNNING + + sched = _RealignSchedulerStub( + requests={ + alive_a.request_id: alive_a, + finished_b.request_id: finished_b, # tracked but is_finished + # untracked_c is NOT in self.requests + alive_d.request_id: alive_d, + }, + running=[alive_a, finished_b, untracked_c, alive_d], + waiting=[], + ) + sched._purge_finished_from_running() + + assert sched.running == [alive_a, alive_d] + + def test_in_place_mutation_keeps_helper_local_list_identity(self) -> None: + """The slice-assign form (``self.running[:] = ...``) avoids an + extra rebind inside this helper. Note that across a full + ``finish_requests`` call upstream ``Scheduler.finish_requests`` + already rebinds ``self.running``, so global identity is not a + contract -- this test only pins the local helper behavior so a + future refactor away from in-place mutation is intentional. + """ + finished = _make_request(request_id="req-finished") + finished.status = RequestStatus.FINISHED_STOPPED + alive = _make_request(request_id="req-alive") + alive.status = RequestStatus.RUNNING + + sched = _RealignSchedulerStub( + requests={ + finished.request_id: finished, + alive.request_id: alive, + }, + running=[finished, alive], + waiting=[], + ) + held_ref = sched.running + sched._purge_finished_from_running() + + assert held_ref is sched.running + assert held_ref == [alive] diff --git a/tests/core/sched/test_omni_scheduler_finish_requests_purge.py b/tests/core/sched/test_omni_scheduler_finish_requests_purge.py new file mode 100644 index 000000000..9b564d7d5 --- /dev/null +++ b/tests/core/sched/test_omni_scheduler_finish_requests_purge.py @@ -0,0 +1,158 @@ +"""Integration tests for the post-finish defensive purge of +``self.running`` placed in +``OmniARScheduler.finish_requests`` / +``OmniGenerationScheduler.finish_requests``. + +Pins *placement* — that ``_purge_finished_from_running`` runs AFTER +``super().finish_requests`` so it catches entries upstream's removal +missed (e.g. status mid-transition, connector cleanup popping +``self.requests`` without unwinding ``self.running``). Direct helper +unit tests live in ``test_omni_scheduler_mixin.py``; these tests +exercise the full ``finish_requests`` path end-to-end. + +Regression for the residual self.running slot leak surface paired +with ``_realign_request_status_to_queues`` (vllm-omni#3774). +""" + +from __future__ import annotations + +import pytest +from vllm.v1.request import RequestStatus + +import vllm_omni.core.sched.omni_ar_scheduler as ar_sched_mod +import vllm_omni.core.sched.omni_generation_scheduler as gen_sched_mod +from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler +from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _StubRequest: + """Minimal Request stub with the surface the helpers exercise.""" + + def __init__(self, request_id: str, status: RequestStatus) -> None: + self.request_id = request_id + self.status = status + + def is_finished(self) -> bool: + return RequestStatus.is_finished(self.status) + + +_SCHEDULER_PARAMS = [ + pytest.param(OmniARScheduler, ar_sched_mod, id="ar"), + pytest.param(OmniGenerationScheduler, gen_sched_mod, id="generation"), +] + + +def _make_scheduler(scheduler_cls, *, requests, running, waiting): + """Bare scheduler with just the surface ``finish_requests`` reads.""" + scheduler = scheduler_cls.__new__(scheduler_cls) + scheduler.chunk_transfer_adapter = None + scheduler.input_coordinator = None + scheduler.requests = requests + scheduler.running = running + scheduler.waiting = waiting + return scheduler + + +@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS) +def test_finish_requests_purges_finished_request_from_running( + monkeypatch: pytest.MonkeyPatch, + scheduler_cls, + scheduler_mod, +) -> None: + """A request whose ``status`` is a finished value but still lives + in ``self.running`` after ``super().finish_requests`` returns must + be swept by the post-finish purge. Mocks ``super()`` to simulate + the corner case where upstream's status-driven removal missed the + entry. + """ + finished = _StubRequest("req-finished", RequestStatus.FINISHED_STOPPED) + scheduler = _make_scheduler( + scheduler_cls, + requests={finished.request_id: finished}, + running=[finished], + waiting=[], + ) + + def fake_finish_requests(self, request_ids, finished_status): + # super() returning the abort tuple but leaving self.running untouched + return [(finished.request_id, 0)] + + monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) + + result = scheduler_cls.finish_requests( + scheduler, + [finished.request_id], + RequestStatus.FINISHED_STOPPED, + ) + + assert scheduler.running == [] + assert result == [(finished.request_id, 0)] + + +@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS) +def test_finish_requests_purges_untracked_request_from_running( + monkeypatch: pytest.MonkeyPatch, + scheduler_cls, + scheduler_mod, +) -> None: + """A request that has been deleted from ``self.requests`` (e.g. + after upstream's ``_free_request`` ran but a status mismatch + caused the ``self.running`` removal branch to no-op) must be + swept by the post-finish purge. + """ + untracked = _StubRequest("req-untracked", RequestStatus.RUNNING) + scheduler = _make_scheduler( + scheduler_cls, + requests={}, # already deleted from self.requests + running=[untracked], + waiting=[], + ) + + def fake_finish_requests(self, request_ids, finished_status): + return [(untracked.request_id, 0)] + + monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) + + result = scheduler_cls.finish_requests( + scheduler, + [untracked.request_id], + RequestStatus.FINISHED_STOPPED, + ) + + assert scheduler.running == [] + assert result == [(untracked.request_id, 0)] + + +@pytest.mark.parametrize(("scheduler_cls", "scheduler_mod"), _SCHEDULER_PARAMS) +def test_finish_requests_leaves_healthy_running_intact( + monkeypatch: pytest.MonkeyPatch, + scheduler_cls, + scheduler_mod, +) -> None: + """A live ``RUNNING`` request that is not in the finish set must + not be swept by the defensive purge -- this pins that the predicate + is conservative and won't aggressively drop healthy entries. + """ + alive = _StubRequest("req-alive", RequestStatus.RUNNING) + leaving_id = "req-leaving" + scheduler = _make_scheduler( + scheduler_cls, + requests={alive.request_id: alive}, + running=[alive], # leaving already removed by super() (typical happy path) + waiting=[], + ) + + def fake_finish_requests(self, request_ids, finished_status): + return [(leaving_id, 0)] + + monkeypatch.setattr(scheduler_mod.VLLMScheduler, "finish_requests", fake_finish_requests) + + scheduler_cls.finish_requests( + scheduler, + [leaving_id], + RequestStatus.FINISHED_STOPPED, + ) + + assert scheduler.running == [alive] diff --git a/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py b/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py index 4088cbf78..fcac8172b 100644 --- a/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py +++ b/tests/core/sched/test_omni_scheduler_input_coordinator_cleanup.py @@ -46,6 +46,9 @@ def test_finish_requests_cleans_input_coordinator_for_finished_ids( scheduler = scheduler_cls.__new__(scheduler_cls) scheduler.chunk_transfer_adapter = None scheduler.input_coordinator = coordinator + scheduler.requests = {} + scheduler.running = [] + scheduler.waiting = [] def fake_finish_requests(self, request_ids, finished_status): assert request_ids == ["req-a", "req-b"] diff --git a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py index f8eb6af8b..ab671ec66 100644 --- a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py +++ b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py @@ -387,14 +387,15 @@ def test_process_and_restore_queues(build_adapter): running_req = _req("r1", RequestStatus.RUNNING) waiting_queue = DummyWaitingQueue([waiting_req]) running_queue = [running_req] + scheduler_requests = {waiting_req.request_id: waiting_req, running_req.request_id: running_req} - adapter.process_pending_chunks(waiting_queue, running_queue) + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests) assert waiting_req.status == RequestStatus.WAITING_FOR_CHUNK assert running_req.status == RequestStatus.WAITING_FOR_CHUNK assert waiting_queue == [] assert running_queue == [] - adapter.restore_queues(waiting_queue, running_queue) + adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests) assert waiting_queue == [waiting_req] assert running_queue == [running_req] assert adapter.waiting_for_chunk_waiting_requests == deque() @@ -897,6 +898,8 @@ def test_omni_ar_scheduler_finish_requests(mocker: MockerFixture): sched = OmniARScheduler.__new__(OmniARScheduler) sched.chunk_transfer_adapter = adapter sched.requests = {} + sched.running = [] + sched.waiting = [] with patch.object(VLLMScheduler, "finish_requests", _super_finish): OmniARScheduler.finish_requests(sched, ["r1"], RequestStatus.FINISHED_ABORTED) @@ -1170,3 +1173,164 @@ def test_deferred_finish_not_finished_still_emits_output(mocker: MockerFixture): assert eco.outputs[0].finish_reason == "stop" assert eco.outputs[0].kv_transfer_params is None assert scheduler._pending_finish_reqs == [] + + +# --------------------------------------------------------------- +# Zombie purge tests (regression for vllm-project/vllm-omni#3736) +# --------------------------------------------------------------- + + +def test_process_pending_chunks_purges_zombies_in_running_deque( + build_adapter, +): + """A request aborted while parked in ``waiting_for_chunk_running_requests`` + is no longer tracked by ``scheduler.requests`` once + ``Scheduler._free_request`` runs. ``process_pending_chunks`` must drop + that zombie *before* ``restore_queues`` would re-inject it onto the + running queue. + + Regression for https://github.com/vllm-project/vllm-omni/issues/3736 + (engine-core ``KeyError`` on aborts under chunk-transfer pressure). + """ + adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) + + live_req = _req("live-1", RequestStatus.WAITING_FOR_CHUNK) + zombie_req = _req("zombie-1", RequestStatus.WAITING_FOR_CHUNK) + adapter.waiting_for_chunk_running_requests.append(live_req) + adapter.waiting_for_chunk_running_requests.append(zombie_req) + # Mirror state the adapter would carry for an in-flight request so we can + # later assert ``cleanup_receiver`` actually fired against the zombie. + adapter.requests_origin_status[zombie_req.request_id] = RequestStatus.RUNNING + adapter.requests_origin_status[live_req.request_id] = RequestStatus.RUNNING + + waiting_queue = DummyWaitingQueue() + running_queue: list = [] + # Simulate the post-abort scheduler: only ``live-1`` is still tracked. + scheduler_requests = {live_req.request_id: live_req} + + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + + # 1. Zombie was popped from the deque. + assert zombie_req not in adapter.waiting_for_chunk_running_requests + # 2. Live request is still in the deque. + assert live_req in adapter.waiting_for_chunk_running_requests + # 3. ``cleanup_receiver`` ran for the zombie (drops origin-status mapping + # and registers the id as cancelled so a late load/poll is dropped too). + assert zombie_req.request_id not in adapter.requests_origin_status + assert zombie_req.request_id in adapter._cancelled_load_reqs + # 4. Live request's bookkeeping is untouched. + assert adapter.requests_origin_status[live_req.request_id] == RequestStatus.RUNNING + assert live_req.request_id not in adapter._cancelled_load_reqs + + # 5. ``restore_queues`` (which the scheduler runs in its ``finally`` + # clause) now only re-injects the live request -- the zombie is + # gone, so the worker's ``_update_states`` cannot crash on it. + adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + assert running_queue == [live_req] + + +def test_process_pending_chunks_purges_zombies_in_waiting_deque(build_adapter): + """Zombie purge applies symmetrically to the waiting-side deque. + + Regression for https://github.com/vllm-project/vllm-omni/issues/3736 -- + aborted requests that landed in ``waiting_for_chunk_waiting_requests`` + must also be removed before ``restore_queues`` re-injects them, since + ``restore_queues`` uses ``waiting_queue.add_request`` rather than + ``running_queue.extend`` for that path. + """ + adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) + + live_req = _req("live-w", RequestStatus.WAITING_FOR_CHUNK) + zombie_req = _req("zombie-w", RequestStatus.WAITING_FOR_CHUNK) + adapter.waiting_for_chunk_waiting_requests.append(live_req) + adapter.waiting_for_chunk_waiting_requests.append(zombie_req) + + waiting_queue = DummyWaitingQueue() + running_queue: list = [] + scheduler_requests = {live_req.request_id: live_req} + + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + + assert live_req in adapter.waiting_for_chunk_waiting_requests + assert zombie_req not in adapter.waiting_for_chunk_waiting_requests + assert zombie_req.request_id in adapter._cancelled_load_reqs + + adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + assert waiting_queue == [live_req] + + +def test_purge_preserves_live_order_with_interleaved_zombies(build_adapter): + """Interleaved live/zombie ordering -- pin the ``popleft`` + append-live + in-place filter at chunk_transfer_adapter._purge_untracked_chunk_requests: + survivor order must match insertion order, and ``cleanup_receiver`` must + run exactly once per zombie. + """ + adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) + + live1 = _req("live-1", RequestStatus.WAITING_FOR_CHUNK) + zombie1 = _req("zombie-1", RequestStatus.WAITING_FOR_CHUNK) + live2 = _req("live-2", RequestStatus.WAITING_FOR_CHUNK) + zombie2 = _req("zombie-2", RequestStatus.WAITING_FOR_CHUNK) + for req in (live1, zombie1, live2, zombie2): + adapter.waiting_for_chunk_running_requests.append(req) + + waiting_queue = DummyWaitingQueue() + running_queue: list = [] + scheduler_requests = {live1.request_id: live1, live2.request_id: live2} + + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + + assert list(adapter.waiting_for_chunk_running_requests) == [live1, live2] + assert zombie1.request_id in adapter._cancelled_load_reqs + assert zombie2.request_id in adapter._cancelled_load_reqs + + +def test_restore_queues_purges_late_aborts_after_process_pending_chunks( + build_adapter, +): + """Race window guard: an abort can fire between ``process_pending_chunks`` + and the scheduler's ``finally``-clause ``restore_queues`` call. The + second purge inside ``restore_queues`` must drop the now-untracked + request so it does not get re-injected onto ``running_queue``. + """ + adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) + + req = _req("late-abort", RequestStatus.WAITING_FOR_CHUNK) + adapter.waiting_for_chunk_running_requests.append(req) + + waiting_queue = DummyWaitingQueue() + running_queue: list = [] + + # Tick N: process_pending_chunks runs while req is still tracked. + scheduler_requests: dict = {req.request_id: req} + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + assert req in adapter.waiting_for_chunk_running_requests + + # Mid-tick: abort fires and the scheduler's free path deletes the entry. + del scheduler_requests[req.request_id] + + # finally: restore_queues sees the now-untracked req and must drop it + # instead of blindly extending it onto running_queue. + adapter.restore_queues(waiting_queue, running_queue, scheduler_requests=scheduler_requests) + assert running_queue == [] + assert req.request_id in adapter._cancelled_load_reqs + + +def test_purge_is_noop_on_empty_deques(build_adapter): + """Empty deques short-circuit -- guards against any accidental + ``IndexError`` from ``popleft`` on an empty deque if a future caller + shadows the empty check. + """ + adapter, _ = build_adapter(stage_id=1, max_num_seqs=8) + assert len(adapter.waiting_for_chunk_waiting_requests) == 0 + assert len(adapter.waiting_for_chunk_running_requests) == 0 + + waiting_queue = DummyWaitingQueue() + running_queue: list = [] + + adapter.process_pending_chunks(waiting_queue, running_queue, scheduler_requests={}) + assert len(adapter.waiting_for_chunk_waiting_requests) == 0 + assert len(adapter.waiting_for_chunk_running_requests) == 0 + adapter.restore_queues(waiting_queue, running_queue, scheduler_requests={}) + assert running_queue == [] + assert waiting_queue == [] diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index 67514994e..4c4ae361a 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -218,7 +218,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): self._process_pending_input_timeouts() if self.chunk_transfer_adapter: - self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running) + self.chunk_transfer_adapter.process_pending_chunks( + self.waiting, self.running, scheduler_requests=self.requests + ) try: scheduler_output = super().schedule() @@ -626,7 +628,34 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): if self.chunk_transfer_adapter: self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests) + # Realign stale ``request.status`` (chunk-transfer-adapter's + # ``requests_origin_status`` table doesn't follow the + # ``waiting → running`` admit transition; without this, an abort + # arriving between admit and the next deque round-trip leaves + # the request in ``self.running`` with ``status=WAITING`` and + # upstream ``Scheduler.finish_requests`` silently fails to + # release the worker's ``input_batch`` slot -- after + # ``max_num_seqs`` such aborts new requests hang at + # ``chunks=0``). Only the ``async_chunk`` path triggers the + # staleness; with ``async_chunk`` disabled this is a cheap O(n) + # no-op over an already-aligned set, kept unconditional so the + # abort path stays uniform across configurations. See + # ``OmniSchedulerMixin._realign_request_status_to_queues`` and + # #3774 discussion. + self._realign_request_status_to_queues(request_ids) + finished = super().finish_requests(request_ids, finished_status) + + # Defensive post-finish purge: belt-and-suspenders to the + # realignment above. Even after realign + ``super()``, corner + # cases (mid-transition status, connector cleanups that pop + # from ``self.requests`` without unwinding ``self.running``) + # can leave already-finished or untracked entries in + # ``self.running``. Sweep them now so the worker's + # ``input_batch`` slot never pins a freed request and starves + # new admissions. See ``OmniSchedulerMixin._purge_finished_from_running``. + self._purge_finished_from_running() + input_coordinator = getattr(self, "input_coordinator", None) if input_coordinator is not None: for request_id, _ in finished: diff --git a/vllm_omni/core/sched/omni_generation_scheduler.py b/vllm_omni/core/sched/omni_generation_scheduler.py index 1c43b958a..9f5d3e104 100644 --- a/vllm_omni/core/sched/omni_generation_scheduler.py +++ b/vllm_omni/core/sched/omni_generation_scheduler.py @@ -87,7 +87,9 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler): self._consume_pending_connector_output(model_mode="generation") self._process_pending_input_timeouts() if self.chunk_transfer_adapter: - self.chunk_transfer_adapter.process_pending_chunks(self.waiting, self.running) + self.chunk_transfer_adapter.process_pending_chunks( + self.waiting, self.running, scheduler_requests=self.requests + ) # OMNI: Track requests that are already finished (e.g., marked by connector) # These should be removed from running and not scheduled @@ -364,7 +366,23 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler): if self.chunk_transfer_adapter: self.chunk_transfer_adapter.finish_requests(request_ids, finished_status, self.requests) + # See ``OmniSchedulerMixin._realign_request_status_to_queues`` -- + # closes the residual hang on the + # ``waiting → running → abort-before-next-deque-round-trip`` race + # surfaced by #3774's reproduction matrix. Only the + # ``async_chunk`` path actually triggers the staleness; with + # ``async_chunk`` disabled this is a cheap O(n) no-op, kept + # unconditional so the abort path stays uniform. + self._realign_request_status_to_queues(request_ids) + finished = super().finish_requests(request_ids, finished_status) + + # See ``OmniSchedulerMixin._purge_finished_from_running`` -- + # defensive belt-and-suspenders sweep paired with the realign + # above. Closes residual ``self.running`` slot leaks if any + # corner case slips past upstream's status-driven removal. + self._purge_finished_from_running() + if self.input_coordinator is not None: for request_id, _ in finished: self._free_input_coordinator_request(request_id) diff --git a/vllm_omni/core/sched/omni_scheduler_mixin.py b/vllm_omni/core/sched/omni_scheduler_mixin.py index c33d16dac..165c04e96 100644 --- a/vllm_omni/core/sched/omni_scheduler_mixin.py +++ b/vllm_omni/core/sched/omni_scheduler_mixin.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import time +from collections.abc import Iterable from typing import Any from vllm.logger import init_logger @@ -156,3 +157,122 @@ class OmniSchedulerMixin: return None self._last_stats_time = now return super().make_stats(*args, **kwargs) + + def _realign_request_status_to_queues( + self, + request_ids: str | Iterable[str] | None, + ) -> None: + """Realign ``request.status`` to actual queue membership. + + ``OmniChunkTransferAdapter._process_chunk_queue`` stamps + ``requests_origin_status[req.id] = WAITING`` (or ``RUNNING``) when + first parking a request in a chunk-transfer deque. On the next + tick, when the chunk arrives, ``_process_chunk_queue`` sets + ``request.status = target_status`` and continues, but + ``requests_origin_status`` is left at its first-park value -- no + hook updates it on the ``waiting → running`` admit transition + that ``super().schedule()`` later performs. The table stays + stale until the request makes another deque round-trip. + + If an abort lands in the gap between admit and the next deque + round-trip, ``chunk_transfer_adapter.finish_requests`` reads the + stale ``WAITING`` from ``requests_origin_status``, stomps it + onto ``request.status``, and the upstream + ``Scheduler.finish_requests`` else branch silently fails to + remove from ``self.running`` -- the request stays alive in + ``self.running`` and the worker's ``input_batch`` slot leaks. + After ``max_num_seqs`` such aborts every new request hangs at + ``chunks=0`` until the client times out. + + Realign here: if a request lives in ``self.running`` but its + status is not ``RUNNING``, set it to ``RUNNING``; symmetrically + flip ``RUNNING → WAITING`` when the request is actually in + ``self.waiting``. This is a localized safety net for + ``requests_origin_status`` staleness on the admit transition; + it does not touch the adapter's invariants and is complementary + to the chunk-transfer-adapter deque purge that already runs + inside ``process_pending_chunks`` / ``restore_queues``. + + Note on scope: only the ``async_chunk`` path actually triggers + the ``requests_origin_status`` staleness this helper repairs. + When ``async_chunk`` is disabled, no chunk-transfer round-trip + occurs between admit and finish, so the realignment walk is a + cheap O(n) no-op over an already-aligned set. The call is kept + unconditional in ``finish_requests`` to (a) keep the abort path + uniform and (b) defend any future configuration that re-enables + chunk transfer from rediscovering the same regression. + + See https://github.com/vllm-project/vllm-omni/pull/3774 and the + residual-hang reproduction discussed in that PR. + """ + # Mirror the upstream Scheduler.finish_requests resolution of + # ``request_ids`` so realignment touches exactly the set that + # ``super().finish_requests`` will then walk. + if isinstance(request_ids, str): + ids_to_align: Iterable[str] = (request_ids,) + elif request_ids is None: + ids_to_align = list(self.requests.keys()) + else: + ids_to_align = list(request_ids) + + if not ids_to_align: + return + + running_ids = {r.request_id for r in self.running} + waiting_ids = {r.request_id for r in self.waiting} + + for rid in ids_to_align: + req = self.requests.get(rid) + if req is None or req.is_finished(): + continue + if rid in running_ids and req.status != RequestStatus.RUNNING: + req.status = RequestStatus.RUNNING + elif rid in waiting_ids and req.status == RequestStatus.RUNNING: + req.status = RequestStatus.WAITING + + def _purge_finished_from_running(self) -> None: + """Defensive post-finish sweep of ``self.running``. + + Belt-and-suspenders to ``_realign_request_status_to_queues``: + even after status realignment lets upstream + ``Scheduler.finish_requests`` pick the right removal branch, + a future regression or an unexpected ``status`` mid-transition + could still leave already-finished entries in ``self.running``. + Sweeping here guarantees the worker's ``input_batch`` slot is + not pinned by a freed request. + + Complementary to ``_realign_request_status_to_queues``: realign + is preventive (fix ``status`` before ``super().finish_requests`` + so the right branch fires); this purge is defensive (sweep the + residue after ``super().finish_requests`` so any stale entries + are reclaimed). + + Scope of the predicate. ``is_finished()`` covers entries the + upstream ``finish_requests`` already drained from ``self.requests`` + but failed to remove from ``self.running``; the + ``request_id not in self.requests`` arm catches the same surface + from a different angle and is the post-cleanup mirror of the + deque purge ``_purge_untracked_chunk_requests`` already runs at + the chunk-transfer-adapter layer. It does **not** by itself make + arbitrary direct deletions of ``self.requests`` safe -- callers + that pop ``self.requests`` outside the standard finish path + still have to go through ``_free_request`` (or equivalent) for + block / connector / coordinator cleanup. This sweep only + reclaims the ``self.running`` slot reference. + + In-place via ``self.running[:] = ...`` for minor consistency + with idiomatic vLLM scheduler mutation; upstream + ``Scheduler.finish_requests`` itself rebinds ``self.running``, + so list identity across the whole call is not preserved -- the + slice form is just to avoid an extra rebind inside this helper. + + Assumes the upstream V1 invariant that scheduler ticks are + serialized on a single thread; in-place mutation here is no more + racy than the rest of the scheduler under that assumption. + + See https://github.com/vllm-project/vllm-omni/pull/3774 + discussion. + """ + if not self.running: + return + self.running[:] = [req for req in self.running if not req.is_finished() and req.request_id in self.requests] 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 121fb79ec..eb1c3eec6 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 @@ -444,12 +444,37 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): self, waiting_queue: Any, running_queue: list[Request], + *, + scheduler_requests: dict[str, Request] | None = None, ) -> None: """ Process pending chunks for waiting and running queues. + + When ``scheduler_requests`` is provided, purges any + ``waiting_for_chunk_*_requests`` deque entries whose + ``request_id`` is no longer tracked by it (e.g. after a + mid-flight abort that ran ``Scheduler._free_request``) before + processing chunks. Without this purge, ``restore_queues`` would + later re-inject the freed ``Request`` onto ``running_queue`` and + the worker's ``_update_states`` would crash with ``KeyError`` + reading ``self.requests[req_id]``. See vllm-project/vllm-omni#3736. + + ``scheduler_requests`` is keyword-only and optional; production + schedulers always pass their live request map, while legacy + callers that don't track aborts may omit it to keep the prior + (unguarded) behaviour. """ if self.connector.stage_id == 0: return + + # Purge deque entries whose request was freed mid-flight (abort → + # Scheduler._free_request) before any chunk processing, so neither + # the legacy nor the active-stream path can re-inject a zombie + # Request onto the queues. See vllm-project/vllm-omni#3736. + if scheduler_requests is not None: + self._purge_untracked_chunk_requests(self.waiting_for_chunk_waiting_requests, scheduler_requests) + self._purge_untracked_chunk_requests(self.waiting_for_chunk_running_requests, scheduler_requests) + if self._active_window <= 0: self._process_chunk_queue_legacy( waiting_queue, self.waiting_for_chunk_waiting_requests, RequestStatus.WAITING, self._finished_load_reqs @@ -558,6 +583,29 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): self.requests_origin_status[request.request_id] = target_status waiting_for_chunk_list.append(request) + def _purge_untracked_chunk_requests( + self, + deque_list: deque[Any], + scheduler_requests: dict[str, Request], + ) -> None: + """Drop deque entries whose ``request_id`` is not in + ``scheduler_requests`` and reclaim their receiver-side state. + + Handles requests that were aborted mid-flight while parked in a + chunk-transfer deque: ``Scheduler._free_request`` deleted the + entry from ``scheduler.requests`` but the deque still holds a + reference to the now-freed ``Request``. Order of survivors is + preserved. + """ + if not deque_list: + return + for _ in range(len(deque_list)): + request = deque_list.popleft() + if request.request_id in scheduler_requests: + deque_list.append(request) + else: + self.cleanup_receiver(request.request_id) + def restore_queues( self, waiting_queue: Any, @@ -566,7 +614,23 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): ) -> None: """ Restore requests waiting for chunk to the waiting and running queues. + + Re-runs the zombie purge first to close the race window where an + abort fires *between* ``process_pending_chunks`` and the + ``finally``-clause ``restore_queues`` call. Without the second + purge, ``running_queue.extend(...)`` would still re-inject a + freed ``Request`` and crash the worker on the next tick. + + ``scheduler_requests`` is optional for back-compat with legacy + callers (older tests pass only the two queue arguments). When + provided, it gates both the deque purge and the per-request + admit checks below; when ``None``, the purge is skipped and + every parked request is restored unconditionally (the + pre-purge behavior). """ + if scheduler_requests is not None: + self._purge_untracked_chunk_requests(self.waiting_for_chunk_waiting_requests, scheduler_requests) + self._purge_untracked_chunk_requests(self.waiting_for_chunk_running_requests, scheduler_requests) # Add request waiting for chunk to the waiting and running queue for request in self.waiting_for_chunk_waiting_requests: if scheduler_requests is None or request.request_id in scheduler_requests: From 6ae355b9a998a9a4170df5e83bd2b4b596bbd544 Mon Sep 17 00:00:00 2001 From: FED4 Date: Wed, 10 Jun 2026 11:45:57 +0800 Subject: [PATCH 45/75] [BugFix] Remove pydub dependency for Python 3.13 compatibility (#4035) Signed-off-by: FED4 Co-authored-by: Gao Han --- requirements/common.txt | 1 - tests/helpers/media.py | 29 ++++- tests/test_pydub_removal.py | 182 ++++++++++++++++++++++++++++ vllm_omni/benchmarks/patch/patch.py | 65 ++++++---- 4 files changed, 249 insertions(+), 28 deletions(-) create mode 100644 tests/test_pydub_removal.py diff --git a/requirements/common.txt b/requirements/common.txt index e649450af..fb435218a 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -22,4 +22,3 @@ prettytable>=3.8.0 aenum==3.1.16 pyzmq>=25.0.0 janus>=1.0.0 -pydub diff --git a/tests/helpers/media.py b/tests/helpers/media.py index 5c76a9e1e..bd0152b86 100644 --- a/tests/helpers/media.py +++ b/tests/helpers/media.py @@ -588,15 +588,32 @@ def cosine_similarity_text(text1, text2, n: int = 3): return cosine * length_harmony -def _merge_base64_audio_to_segment(base64_list: list[str]): - from pydub import AudioSegment +class _AudioBuffer: + """Minimal replacement for pydub.AudioSegment used by test helpers.""" - merged = None + def __init__(self, data: np.ndarray, sample_rate: int): + self.data = data + self.sample_rate = sample_rate + + def export(self, buf: io.BytesIO, format: str = "wav"): + sf.write(buf, self.data, self.sample_rate, format=format.upper()) + buf.seek(0) + + +def _merge_base64_audio_to_segment(base64_list: list[str]) -> _AudioBuffer: + from vllm.multimodal.media.audio import AudioMediaIO + + io_ = AudioMediaIO() + chunks: list[np.ndarray] = [] + sample_rate: int | None = None for b64 in base64_list: raw = base64.b64decode(b64.split(",", 1)[-1]) - seg = AudioSegment.from_file(io.BytesIO(raw)) - merged = seg if merged is None else merged + seg - return merged + waveform, sr = io_.load_bytes(raw) + if sample_rate is None: + sample_rate = int(sr) + chunks.append(waveform) + merged = np.concatenate(chunks) if chunks else np.array([], dtype=np.float32) + return _AudioBuffer(merged, sample_rate or 16000) @contextmanager diff --git a/tests/test_pydub_removal.py b/tests/test_pydub_removal.py new file mode 100644 index 000000000..ae65bdab2 --- /dev/null +++ b/tests/test_pydub_removal.py @@ -0,0 +1,182 @@ +"""Functional tests for pydub removal. + +Verifies that the new av/numpy/torchaudio replacements produce +identical results to the old pydub-based code for all three paths: + 1. WAV PCM → duration & frame count (pure arithmetic) + 2. Non-WAV (mp3/ogg) → duration & frame count (av.open) + 3. TTS WER PCM export: resample to 24000Hz mono s16 +""" + +import io +import wave + +import av +import numpy as np +import soundfile as sf + + +def _make_wav_bytes(sample_rate=16000, channels=1, sample_width=2, duration_sec=1.0): + """Generate a WAV file with a sine wave, return (wav_bytes, pcm_bytes, params).""" + n_samples = int(sample_rate * duration_sec) + t = np.linspace(0, duration_sec, n_samples, endpoint=False) + samples = (np.sin(2 * np.pi * 440 * t) * 32767 * 0.5).astype(np.int16) + if channels == 2: + samples = np.column_stack([samples, samples]).flatten() + + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(sample_width) + wf.setframerate(sample_rate) + wf.writeframes(samples.tobytes()) + wav_bytes = buf.getvalue() + + # Extract raw PCM (skip WAV header) + with wave.open(io.BytesIO(wav_bytes), "rb") as wr: + pcm_bytes = wr.readframes(wr.getnframes()) + params = (wr.getnchannels(), wr.getsampwidth(), wr.getframerate()) + + return wav_bytes, pcm_bytes, params + + +def _make_ogg_bytes(sample_rate=16000, channels=1, duration_sec=1.0): + """Generate an OGG Vorbis file with a sine wave, return bytes.""" + n_samples = int(sample_rate * duration_sec) + t = np.linspace(0, duration_sec, n_samples, endpoint=False) + samples = (np.sin(2 * np.pi * 440 * t) * 0.5).astype(np.float32) + buf = io.BytesIO() + sf.write(buf, samples, sample_rate, format="OGG", subtype="VORBIS") + return buf.getvalue() + + +def test_wav_path_duration_and_frames(): + """Path 1: WAV PCM buffer → duration & frames via arithmetic.""" + for sr in [16000, 24000, 44100]: + for ch in [1, 2]: + for dur in [0.5, 1.0, 2.5]: + _, pcm_bytes, (channels, sample_width, frame_rate) = _make_wav_bytes( + sample_rate=sr, channels=ch, duration_sec=dur + ) + + # New code logic (from patch.py) + frame_width = sample_width * channels + audio_frames = len(pcm_bytes) // frame_width + audio_duration_sec = audio_frames / frame_rate + + assert abs(audio_duration_sec - dur) < 0.001, f"sr={sr} ch={ch} dur={dur}: got {audio_duration_sec}" + expected_frames = int(sr * dur) + assert audio_frames == expected_frames, ( + f"sr={sr} ch={ch} dur={dur}: frames {audio_frames} != {expected_frames}" + ) + + print("[PASS] test_wav_path_duration_and_frames") + + +def test_non_wav_path_duration_av(): + """Path 2: non-WAV audio → duration & frames via av.""" + for dur in [0.5, 1.0, 2.0]: + ogg_bytes = _make_ogg_bytes(sample_rate=16000, duration_sec=dur) + + container = av.open(io.BytesIO(ogg_bytes)) + stream = container.streams.audio[0] + audio_frames = sum(f.samples for f in container.decode(stream)) + audio_duration_sec = audio_frames / stream.rate if stream.rate else 0.0 + container.close() + + # OGG encoding may add padding, allow small tolerance + assert abs(audio_duration_sec - dur) < 0.05, f"dur={dur}: got {audio_duration_sec}" + + print("[PASS] test_non_wav_path_duration_av") + + +def test_tts_pcm_export_wav_path(): + """Path 3a: WAV PCM → resample to 24000Hz mono s16.""" + import torch + import torchaudio + + _, pcm_bytes, (channels, sample_width, frame_rate) = _make_wav_bytes( + sample_rate=16000, channels=2, sample_width=2, duration_sec=1.0 + ) + + # New code logic + pcm_array = np.frombuffer(pcm_bytes, dtype=np.int16) + if channels > 1: + pcm_array = pcm_array.reshape(-1, channels).mean(axis=1).astype(np.int16) + if frame_rate != 24000: + t = torch.from_numpy(pcm_array.astype(np.float32)).unsqueeze(0) + t = torchaudio.functional.resample(t, frame_rate, 24000) + pcm_array = t.squeeze(0).to(torch.int16).numpy() + + result_bytes = pcm_array.tobytes() + # Should be mono, 24000Hz, 2 bytes per sample + expected_samples = int(24000 * 1.0) + actual_samples = len(result_bytes) // 2 + assert abs(actual_samples - expected_samples) < 10, f"expected ~{expected_samples} samples, got {actual_samples}" + assert len(pcm_array.shape) == 1, "should be mono (1D array)" + + print("[PASS] test_tts_pcm_export_wav_path") + + +def test_tts_pcm_export_non_wav_path(): + """Path 3b: non-WAV → resample to 24000Hz mono s16 via av.AudioResampler.""" + ogg_bytes = _make_ogg_bytes(sample_rate=16000, channels=1, duration_sec=1.0) + + container = av.open(io.BytesIO(ogg_bytes)) + resampler = av.AudioResampler(format="s16", layout="mono", rate=24000) + frames = [] + for frame in container.decode(audio=0): + for rf in resampler.resample(frame): + frames.append(rf.to_ndarray().flatten()) + container.close() + + result = np.concatenate(frames) + result_bytes = result.tobytes() + expected_samples = int(24000 * 1.0) + actual_samples = len(result_bytes) // 2 + assert abs(actual_samples - expected_samples) < 500, f"expected ~{expected_samples} samples, got {actual_samples}" + + print("[PASS] test_tts_pcm_export_non_wav_path") + + +def test_media_merge_and_export(): + """media.py: _merge_base64_audio_to_segment decode + concat + export WAV.""" + import base64 as b64mod + import sys + + sys.path.insert(0, ".") + from tests.helpers.media import _merge_base64_audio_to_segment + + # Create two WAV chunks as base64 + chunks_b64 = [] + for dur in [0.5, 0.3]: + wav_bytes, _, _ = _make_wav_bytes(sample_rate=16000, channels=1, sample_width=2, duration_sec=dur) + chunks_b64.append(b64mod.b64encode(wav_bytes).decode()) + + merged = _merge_base64_audio_to_segment(chunks_b64) + + # Test export to WAV + buf = io.BytesIO() + merged.export(buf, format="wav") + wav_out = buf.getvalue() + assert len(wav_out) > 44, f"WAV output too small: {len(wav_out)} bytes" + + # Verify merged duration (~0.8s) + out_data, out_sr = sf.read(io.BytesIO(wav_out)) + actual_dur = len(out_data) / out_sr + assert abs(actual_dur - 0.8) < 0.05, f"expected ~0.8s merged duration, got {actual_dur}" + + print(f"[PASS] test_media_merge_and_export (duration={actual_dur:.3f}s, wav_size={len(wav_out)} bytes)") + + +if __name__ == "__main__": + test_wav_path_duration_and_frames() + test_non_wav_path_duration_av() + import importlib.util + + if importlib.util.find_spec("torch") and importlib.util.find_spec("torchaudio"): + test_tts_pcm_export_wav_path() + else: + print("[SKIP] test_tts_pcm_export_wav_path (torch/torchaudio not available)") + test_tts_pcm_export_non_wav_path() + test_media_merge_and_export() + print("\nAll tests passed.") diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index 3357fa8bc..76b8628c1 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -16,8 +16,8 @@ from datetime import datetime from typing import Any, Literal import aiohttp +import numpy as np import pybase64 as base64 -from pydub import AudioSegment from tqdm.asyncio import tqdm from vllm.benchmarks import datasets from vllm.benchmarks.datasets import SampleRequest @@ -653,9 +653,8 @@ async def async_request_openai_chat_omni_completions( # Reset per-attempt state so that retries do not mix partial # outputs or metrics from previous attempts. generated_text = "" - generated_audio = None # For wav responses, accumulate decoded PCM bytes per chunk - # to avoid repeated AudioSegment decode/concat. + # to avoid repeated decode/concat. wav_pcm_buffer = bytearray() wav_audio_params: tuple[int, int, int] | None = None wav_inconsistent_chunk_count = 0 @@ -795,30 +794,33 @@ async def async_request_openai_chat_omni_completions( output.latency = timestamp - st output.generated_text = generated_text + audio_duration_sec = 0.0 + audio_frames = 0 if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None: channels, sample_width, frame_rate = wav_audio_params - generated_audio = AudioSegment( - data=bytes(wav_pcm_buffer), - sample_width=sample_width, - frame_rate=frame_rate, - channels=channels, - ) + frame_width = sample_width * channels + if frame_width > 0: + audio_frames = len(wav_pcm_buffer) // frame_width + audio_duration_sec = audio_frames / frame_rate + else: + logger.warning("Audio frame width is zero") elif audio_bytes_buffer: try: - generated_audio = AudioSegment.from_file( + from vllm.multimodal.audio import get_audio_duration + from vllm.multimodal.media.audio import load_audio + + waveform, sr = load_audio( io.BytesIO(bytes(audio_bytes_buffer)), - format=response_format, + sr=None, + mono=False, ) + audio_duration_sec = get_audio_duration(y=waveform, sr=sr) + audio_frames = int(audio_duration_sec * sr) except Exception as ex: logger.warning("Failed to decode accumulated audio bytes: %s", ex) - if generated_audio is not None: - output.audio_duration = len(generated_audio) / 1000.0 - frame_width = generated_audio.frame_width - if frame_width > 0: - output.audio_frames = len(generated_audio.raw_data) // frame_width - else: - output.audio_frames = 0 - logger.warning("Audio frame width is zero") + if audio_duration_sec > 0 or audio_frames > 0: + output.audio_duration = audio_duration_sec + output.audio_frames = audio_frames audio_duration = output.audio_duration if audio_duration > 0: output.audio_rtf = audio_generate_time / output.audio_duration @@ -827,8 +829,29 @@ async def async_request_openai_chat_omni_completions( logger.warning("Audio duration is zero") if _seed_tts_capture_pcm_for_wer() and getattr(request_func_input, "seed_tts_row", False): try: - seg = generated_audio.set_frame_rate(24000).set_channels(1).set_sample_width(2) - output.tts_output_pcm_bytes = bytes(seg.raw_data) + if response_format == "wav" and wav_pcm_buffer and wav_audio_params is not None: + from vllm.multimodal.audio import AudioResampler + + pcm_channels, pcm_sw, pcm_rate = wav_audio_params + pcm = np.frombuffer( + bytes(wav_pcm_buffer), dtype=np.int16 if pcm_sw == 2 else np.float32 + ) + if pcm_channels > 1: + pcm = pcm.reshape(-1, pcm_channels).mean(axis=1).astype(pcm.dtype) + pcm_f32 = pcm.astype(np.float32) / 32767.0 if pcm.dtype == np.int16 else pcm + if pcm_rate != 24000: + resampler = AudioResampler(target_sr=24000) + pcm_f32 = resampler.resample(pcm_f32, orig_sr=pcm_rate) + output.tts_output_pcm_bytes = (pcm_f32 * 32767).astype(np.int16).tobytes() + elif audio_bytes_buffer: + from vllm.multimodal.media.audio import load_audio + + waveform, _ = load_audio( + io.BytesIO(bytes(audio_bytes_buffer)), + sr=24000, + mono=True, + ) + output.tts_output_pcm_bytes = (waveform * 32767).astype(np.int16).tobytes() except Exception as ex: logger.warning("seed_tts WER PCM export failed: %s", ex) output.success = True From 57c8f3abfc535ef5b5818f3f14b90b56c06fdd79 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:27:27 +0800 Subject: [PATCH 46/75] [CI] skip unrelated L3 merge tests via diff-aware upload (#4291) Signed-off-by: wangyu <410167048@qq.com> Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com> --- .buildkite/pipeline.yml | 8 +- .buildkite/rebase-pipeline.yaml | 2 +- .buildkite/scripts/upload_pipeline.py | 308 ++++++++++++++++++ .../scripts/upload_pipeline_with_skip_ci.sh | 146 --------- .buildkite/test-merge.yml | 123 +++++-- .buildkite/test-ready.yml | 27 +- .../{test_voxcpm2.py => test_voxcpm2_tts.py} | 0 7 files changed, 413 insertions(+), 201 deletions(-) create mode 100644 .buildkite/scripts/upload_pipeline.py delete mode 100644 .buildkite/scripts/upload_pipeline_with_skip_ci.sh rename tests/e2e/offline_inference/{test_voxcpm2.py => test_voxcpm2_tts.py} (100%) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 96aa9e8ba..5cc52f2d5 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -1,14 +1,14 @@ # Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only skip-ci # from git diff, then uploads document 2. When docs-only skip applies, image-build still runs for the nightly -# exception in upload_pipeline_with_skip_ci.sh (nightly-test label or main NIGHTLY=1); otherwise upload-nightly +# exception in upload_pipeline.py (nightly-test label or main NIGHTLY=1); otherwise upload-nightly # can be skipped together with test-ready/test-merge. Weekly uploads also depend on image-build. -# Document 2: appended after `---`; same file, read by upload_pipeline_with_skip_ci.sh (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml. +# Document 2: appended after `---`; same file, read by upload_pipeline.py (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml. steps: - label: ":github: Resolve skip-ci & upload pipeline" key: upload-ci-pipeline commands: - - "bash .buildkite/scripts/upload_pipeline_with_skip_ci.sh" + - "python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/pipeline.yml" agents: queue: "cpu_queue_premerge" @@ -41,7 +41,7 @@ steps: key: upload-merge-pipeline if: __UPLOAD_MERGE_IF__ commands: - - buildkite-agent pipeline upload .buildkite/test-merge.yml + - python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-merge.yml agents: queue: "cpu_queue_premerge" diff --git a/.buildkite/rebase-pipeline.yaml b/.buildkite/rebase-pipeline.yaml index edd98fafd..3ae553fd3 100644 --- a/.buildkite/rebase-pipeline.yaml +++ b/.buildkite/rebase-pipeline.yaml @@ -23,7 +23,7 @@ steps: depends_on: image-build key: upload-merge-pipeline commands: - - buildkite-agent pipeline upload .buildkite/test-merge.yml + - python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-merge.yml agents: queue: "cpu_queue_premerge" diff --git a/.buildkite/scripts/upload_pipeline.py b/.buildkite/scripts/upload_pipeline.py new file mode 100644 index 000000000..1a1254dbc --- /dev/null +++ b/.buildkite/scripts/upload_pipeline.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Render and optionally upload Buildkite pipeline YAML with diff-aware logic. + +Bootstrap mode (pipeline.yml with __IMAGE_BUILD_IF__ placeholders): + - Detect docs-only PR/main changes and substitute skip-ci ``if`` expressions. + +Test pipeline mode (e.g. test-merge.yml): + - Drop steps or groups whose ``source_file_dependencies`` do not match changed files. + - ``source_file_dependencies`` may be set on a leaf step or on a group step. + - Always strip ``source_file_dependencies`` from uploaded YAML (Buildkite does not + recognize this uploader-only key). + +Usage: + python3 upload_pipeline.py [--upload] + + # Bootstrap (replaces upload_pipeline_with_skip_ci.sh): + python3 upload_pipeline.py --upload .buildkite/pipeline.yml + + # Test pipeline (replaces upload_test_pipeline_with_diff_skip.py): + python3 upload_pipeline.py --upload .buildkite/test-merge.yml +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +try: + import yaml +except ImportError: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-q", "pyyaml"], + ) + import yaml + +LOG = "upload_pipeline" +ROOT = Path(__file__).resolve().parent.parent.parent +DOC_SEP = "\n---\n" +BOOTSTRAP_MARKER = "__IMAGE_BUILD_IF__" + + +def _log(message: str) -> None: + print(f"{LOG}: {message}", file=sys.stderr) + + +def _git(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + check=False, + capture_output=True, + text=True, + ) + + +def resolve_changed_files() -> list[str] | None: + """Return changed file paths, or None when diff cannot be resolved.""" + is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get( + "BUILDKITE_PULL_REQUEST", "" + ) + commit = os.environ.get("BUILDKITE_COMMIT", "") + + if is_pr: + base_branch = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main") + base_ref = f"origin/{base_branch}" + if _git("rev-parse", "--verify", base_ref).returncode != 0: + _log(f"origin/{base_branch} not found locally; trying fetch") + _git("fetch", "--depth=200", "origin", base_branch) + if _git("rev-parse", "--verify", base_ref).returncode != 0: + if _git("rev-parse", "--verify", base_branch).returncode == 0: + base_ref = base_branch + else: + _log(f"cannot resolve PR base {base_branch}; using safe defaults") + return None + diff_range = f"{base_ref}...{commit}" + elif os.environ.get("BUILDKITE_BRANCH", "") == "main": + if _git("rev-parse", "--verify", f"{commit}^").returncode != 0: + _log("main commit has no parent; using safe defaults") + return None + diff_range = f"{commit}^..{commit}" + else: + _log("not PR/main build; using safe defaults") + return None + + result = _git("diff", "--name-only", diff_range) + if result.returncode != 0: + _log(f"git diff failed ({diff_range}); using safe defaults") + return None + + files = [line for line in result.stdout.splitlines() if line.strip()] + _log(f"{len(files)} changed file(s)") + return files + + +def is_docs_only_change(changed_files: list[str]) -> bool: + has_any = False + for file_path in changed_files: + if not file_path: + continue + has_any = True + if file_path.startswith("docs/"): + continue + if file_path.endswith(".md"): + continue + if file_path == "mkdocs.yml": + continue + return False + return has_any + + +def resolve_skip_ci(changed_files: list[str] | None) -> bool: + if changed_files is None: + _log("skip-ci=0 (could not resolve changed files)") + return False + if is_docs_only_change(changed_files): + _log("docs-only change detected; skip-ci=1") + return True + _log("non-doc changes detected; skip-ci=0") + return False + + +def render_bootstrap_pipeline(text: str, *, skip_ci: bool) -> str: + if DOC_SEP in text: + _, continuation = text.split(DOC_SEP, 1) + else: + continuation = text + + nightly_only = ( + '(build.pull_request.labels includes "nightly-test") || (build.branch == "main" && build.env("NIGHTLY") == "1")' + ) + if skip_ci: + image_if = f"'{nightly_only}'" + ready_if = "'false'" + merge_if = "'false'" + else: + image_if = "'true'" + ready_if = '\'build.branch != "main" && build.pull_request.labels includes "ready"\'' + merge_if = ( + '\'(build.branch == "main" && build.env("NIGHTLY") != "1" ' + '&& build.env("WEEKLY") != "1") || ' + '(build.branch != "main" && build.pull_request.labels includes "merge-test")\'' + ) + + return ( + continuation.replace("__IMAGE_BUILD_IF__", image_if) + .replace("__UPLOAD_READY_IF__", ready_if) + .replace("__UPLOAD_MERGE_IF__", merge_if) + ) + + +def _matches_dependencies(changed_files: list[str], prefixes: list[str]) -> bool: + for path in changed_files: + for prefix in prefixes: + normalized = prefix.rstrip("/") + if path == normalized or path.startswith(f"{normalized}/"): + return True + return False + + +def _strip_source_file_dependencies(step: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in step.items() if key != "source_file_dependencies"} + + +def _step_label(step: dict[str, Any]) -> str: + return str(step.get("group") or step.get("label") or "") + + +def _filter_steps(steps: list[Any], changed_files: list[str]) -> list[Any]: + filtered: list[Any] = [] + for step in steps: + if not isinstance(step, dict): + filtered.append(step) + continue + + deps = step.get("source_file_dependencies") + if deps is not None and not isinstance(deps, list): + raise ValueError( + f"source_file_dependencies must be a list in step {_step_label(step)!r}", + ) + if deps is not None and not _matches_dependencies(changed_files, deps): + _log( + f"skip {_step_label(step)!r} (no changes under {deps})", + ) + continue + + nested = step.get("steps") + if nested is not None: + kept_nested = _filter_steps(nested, changed_files) + if not kept_nested: + _log(f"omit empty group {_step_label(step)!r}") + continue + new_step = _strip_source_file_dependencies(step) + new_step["steps"] = kept_nested + filtered.append(new_step) + continue + + if deps is not None: + filtered.append(_strip_source_file_dependencies(step)) + else: + filtered.append(step) + + return filtered + + +def _strip_uploader_metadata_from_steps(steps: list[Any]) -> list[Any]: + """Remove uploader-only keys while keeping all steps (no diff filtering).""" + stripped: list[Any] = [] + for step in steps: + if not isinstance(step, dict): + stripped.append(step) + continue + + deps = step.get("source_file_dependencies") + if deps is not None and not isinstance(deps, list): + raise ValueError( + f"source_file_dependencies must be a list in step {_step_label(step)!r}", + ) + + nested = step.get("steps") + new_step = _strip_source_file_dependencies(step) + if nested is not None: + new_step["steps"] = _strip_uploader_metadata_from_steps(nested) + stripped.append(new_step) + + return stripped + + +def render_test_pipeline( + doc: dict[str, Any], + changed_files: list[str] | None, +) -> dict[str, Any]: + steps = doc.get("steps") + if not isinstance(steps, list): + return doc + if changed_files is not None: + steps = _filter_steps(steps, changed_files) + else: + steps = _strip_uploader_metadata_from_steps(steps) + return {**doc, "steps": steps} + + +def resolve_pipeline_path(arg: str) -> Path: + path = Path(arg) + if path.is_absolute(): + return path + return ROOT / path + + +def render_pipeline(path: Path) -> str: + text = path.read_text(encoding="utf-8") + changed_files = resolve_changed_files() + + if BOOTSTRAP_MARKER in text: + rendered = render_bootstrap_pipeline(text, skip_ci=resolve_skip_ci(changed_files)) + return rendered + + doc = yaml.safe_load(text) + if not isinstance(doc, dict): + raise ValueError(f"invalid pipeline YAML: {path}") + + doc = render_test_pipeline(doc, changed_files) + + return yaml.safe_dump(doc, sort_keys=False) + + +def upload_to_buildkite(content: str) -> None: + subprocess.run( + ["buildkite-agent", "pipeline", "upload"], + input=content, + text=True, + check=True, + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "pipeline", + nargs="?", + default=".buildkite/pipeline.yml", + help="Pipeline YAML path (default: .buildkite/pipeline.yml)", + ) + parser.add_argument( + "--upload", + action="store_true", + help="Pipe rendered YAML to buildkite-agent pipeline upload", + ) + args = parser.parse_args() + + path = resolve_pipeline_path(args.pipeline) + if not path.is_file(): + _log(f"missing pipeline file: {path}") + return 1 + + rendered = render_pipeline(path) + if args.upload: + upload_to_buildkite(rendered) + else: + sys.stdout.write(rendered) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.buildkite/scripts/upload_pipeline_with_skip_ci.sh b/.buildkite/scripts/upload_pipeline_with_skip_ci.sh deleted file mode 100644 index c19fe7063..000000000 --- a/.buildkite/scripts/upload_pipeline_with_skip_ci.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -# Evaluate docs-only skip-ci and upload continuation steps from the given pipeline YAML -# (YAML document after the first `---`). Buildkite `if` is evaluated at upload time. -# -# Usage: upload_pipeline_with_skip_ci.sh [pipeline_yaml] -# pipeline_yaml: path relative to repo root or absolute (default: .buildkite/pipeline.yml) -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -PIPELINE_ARG="${1:-.buildkite/pipeline.yml}" -if [[ "${PIPELINE_ARG}" = /* ]]; then - PIPELINE_YML="${PIPELINE_ARG}" -else - PIPELINE_YML="${ROOT}/${PIPELINE_ARG}" -fi - -# Prints a single digit to stdout: 1 = skip image CI, 0 = run. Logs go to stderr. -is_docs_only_change() { - local file_path - local has_any=0 - - while IFS= read -r file_path; do - [[ -z "${file_path}" ]] && continue - has_any=1 - - if [[ "${file_path}" == docs/* ]]; then - continue - fi - if [[ "${file_path}" == *.md ]]; then - continue - fi - if [[ "${file_path}" == "mkdocs.yml" ]]; then - continue - fi - return 1 - done - - [[ "${has_any}" -eq 1 ]] -} - -resolve_skip_ci() { - local is_pr_build=0 - local files - local base_branch base_ref - - if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" && -n "${BUILDKITE_PULL_REQUEST:-}" ]]; then - is_pr_build=1 - fi - - if [[ "${is_pr_build}" -eq 1 ]]; then - base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-main}" - if ! git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then - echo "resolve_skip_ci: origin/${base_branch} not found locally; trying fetch" >&2 - git fetch --depth=200 origin "${base_branch}" >/dev/null 2>&1 || true - fi - - base_ref="" - if git rev-parse --verify "origin/${base_branch}" >/dev/null 2>&1; then - base_ref="origin/${base_branch}" - elif git rev-parse --verify "${base_branch}" >/dev/null 2>&1; then - base_ref="${base_branch}" - else - echo "resolve_skip_ci: cannot resolve PR base ${base_branch}; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - - if ! files="$(git diff --name-only "${base_ref}...${BUILDKITE_COMMIT}" 2>/dev/null)"; then - echo "resolve_skip_ci: failed to compute PR changed files; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - elif [[ "${BUILDKITE_BRANCH:-}" == "main" ]]; then - if ! git rev-parse --verify "${BUILDKITE_COMMIT}^" >/dev/null 2>&1; then - echo "resolve_skip_ci: commit has no parent on main; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - if ! files="$(git diff --name-only "${BUILDKITE_COMMIT}^..${BUILDKITE_COMMIT}" 2>/dev/null)"; then - echo "resolve_skip_ci: failed to compute main changed files; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - else - echo "resolve_skip_ci: not PR/main build; skip-ci=0" >&2 - echo -n 0 - return 0 - fi - - if is_docs_only_change <<< "${files}"; then - echo "resolve_skip_ci: docs-only change detected; skip-ci=1" >&2 - echo -n 1 - return 0 - fi - - echo "resolve_skip_ci: non-doc changes detected; skip-ci=0" >&2 - echo -n 0 -} - -SKIP_CI="$(resolve_skip_ci)" - -if [[ ! -f "${PIPELINE_YML}" ]]; then - echo "upload_pipeline_with_skip_ci: missing ${PIPELINE_YML}" >&2 - exit 1 -fi - -export ROOT SKIP_CI PIPELINE_YML -python3 <<'PY' | buildkite-agent pipeline upload -import os -import pathlib - -path = pathlib.Path(os.environ["PIPELINE_YML"]) -text = path.read_text(encoding="utf-8") -sep = "\n---\n" -# Two supported layouts: -# - multi-doc: doc 1 = bootstrap (loaded by Buildkite), doc 2 (after `---`) = uploaded steps. -# - single-doc: whole file is the uploaded steps; caller (UI init step) is the bootstrap. -if sep in text: - _, continuation = text.split(sep, 1) -else: - continuation = text - -skip = os.environ.get("SKIP_CI") == "1" -# When docs-only skip-ci: skip default CI image, but still build for L4 nightly (PR label nightly-test or -# main NIGHTLY=1), otherwise upload-nightly (depends_on image-build) would be skipped too. -nightly_only = ( - '(build.pull_request.labels includes "nightly-test") ' - '|| (build.branch == "main" && build.env("NIGHTLY") == "1")' -) -# Placeholder in pipeline.yml is `if: __IMAGE_BUILD_IF__` (valid YAML); replace value only. -if skip: - rep = f"'{nightly_only}'" - ready_rep = "'false'" - merge_rep = "'false'" -else: - rep = "'true'" - ready_rep = "'build.branch != \"main\" && build.pull_request.labels includes \"ready\"'" - merge_rep = "'(build.branch == \"main\" && build.env(\"NIGHTLY\") != \"1\" && build.env(\"WEEKLY\") != \"1\") || (build.branch != \"main\" && build.pull_request.labels includes \"merge-test\")'" -rendered = ( - continuation - .replace("__IMAGE_BUILD_IF__", rep) - .replace("__UPLOAD_READY_IF__", ready_rep) - .replace("__UPLOAD_MERGE_IF__", merge_rep) -) -print(rendered, end="") -PY diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml index 191b7f214..410695f4b 100644 --- a/.buildkite/test-merge.yml +++ b/.buildkite/test-merge.yml @@ -44,6 +44,9 @@ steps: depends_on: upload-merge-pipeline steps: - label: "Diffusion · LoRA Test" + source_file_dependencies: + - tests/diffusion/lora/ + - vllm_omni/diffusion/lora/ timeout_in_minutes: 30 commands: - pytest -sv tests/diffusion/lora/ -m 'advanced_model and cuda' --run-level "advanced_model" @@ -61,6 +64,9 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "Distributed Test" + source_file_dependencies: + - tests/distributed/ + - vllm_omni/distributed/ timeout_in_minutes: 60 commands: - | @@ -105,6 +111,9 @@ steps: - group: ":card_index_dividers: Entrypoints Test" depends_on: upload-merge-pipeline + source_file_dependencies: + - tests/entrypoints/ + - vllm_omni/entrypoints/ steps: - label: "Entrypoint Test with H100" timeout_in_minutes: 60 @@ -165,6 +174,13 @@ steps: depends_on: upload-merge-pipeline steps: - label: "TTS · Qwen3-TTS CustomVoice Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_customvoice.py + - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml + - vllm_omni/model_executor/stage_configs/qwen3_tts_uniproc.yaml commands: - | timeout 20m bash -c " @@ -186,6 +202,12 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "TTS · Qwen3-TTS Base Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen3_tts_base.py + - tests/e2e/offline_inference/test_qwen3_tts_base.py + - vllm_omni/model_executor/models/qwen3_tts/ + - vllm_omni/model_executor/stage_input_processors/qwen3_tts.py + - vllm_omni/deploy/qwen3_tts.yaml commands: - | timeout 20m bash -c " @@ -207,6 +229,12 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "TTS · Voxtral-TTS Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_voxtral_tts.py + - tests/e2e/online_serving/test_voxtral_tts.py + - vllm_omni/model_executor/models/voxtral_tts/ + - vllm_omni/model_executor/stage_input_processors/voxtral_tts.py + - vllm_omni/deploy/voxtral_tts.yaml timeout_in_minutes: 20 commands: - | @@ -249,6 +277,12 @@ steps: type: DirectoryOrCreate - label: "TTS · GLM-TTS Test" + source_file_dependencies: + - tests/e2e/online_serving/test_glm_tts.py + - tests/e2e/offline_inference/test_glm_tts.py + - vllm_omni/model_executor/models/glm_tts/ + - vllm_omni/model_executor/stage_input_processors/glm_tts.py + - vllm_omni/deploy/glm_tts.yaml timeout_in_minutes: 20 commands: - | @@ -291,6 +325,12 @@ steps: type: DirectoryOrCreate - label: "Omni · Qwen3-Omni Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_qwen3_omni.py + - tests/e2e/online_serving/test_qwen3_omni.py + - vllm_omni/model_executor/models/qwen3_omni/ + - vllm_omni/model_executor/stage_input_processors/qwen3_omni.py + - vllm_omni/deploy/qwen3_omni_moe.yaml timeout_in_minutes: 30 commands: - pytest -s -v tests/e2e/offline_inference/test_qwen3_omni.py tests/e2e/online_serving/test_qwen3_omni.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -329,6 +369,11 @@ steps: type: DirectoryOrCreate - label: "Omni · Mimo Audio Test" + source_file_dependencies: + - tests/e2e/online_serving/test_mimo_audio.py + - vllm_omni/model_executor/models/mimo_audio/ + - vllm_omni/model_executor/stage_input_processors/mimo_audio.py + - vllm_omni/deploy/mimo_audio.yaml timeout_in_minutes: 30 commands: - pytest -s -v tests/e2e/online_serving/test_mimo_audio.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -366,37 +411,19 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate - - label: "Omni · VoxCPM2 Test" - timeout_in_minutes: 20 - commands: - - | - timeout 20m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/offline_inference/test_voxcpm2.py -m 'advanced_model and cuda' --run-level 'advanced_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · VoxCPM2 Online Test" + - label: "TTS · VoxCPM2 Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_voxcpm2_tts.py + - tests/e2e/online_serving/test_voxcpm2_tts.py + - vllm_omni/model_executor/models/voxcpm2/ + - vllm_omni/deploy/voxcpm2.yaml timeout_in_minutes: 30 commands: - | timeout 30m bash -c " export VLLM_LOGGING_LEVEL=DEBUG export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' + pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' " agents: queue: "gpu_1_queue" @@ -413,6 +440,12 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "Omni · Higgs-Audio V2 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_higgs_audio_v2.py + - tests/e2e/offline_inference/test_higgs_audio_v2.py + - vllm_omni/model_executor/models/higgs_audio_v2/ + - vllm_omni/model_executor/stage_input_processors/higgs_audio_v2.py + - vllm_omni/deploy/higgs_audio_v2.yaml timeout_in_minutes: 30 commands: - | @@ -438,6 +471,9 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "Diffusion · Qwen Image Edit Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_edit.py + - vllm_omni/diffusion/models/qwen_image/ timeout_in_minutes: 20 commands: - pytest -s -v tests/e2e/online_serving/test_qwen_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -476,6 +512,9 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Longcat Image Edit Test" + source_file_dependencies: + - tests/e2e/online_serving/test_longcat_image_edit.py + - vllm_omni/diffusion/models/longcat_image/ timeout_in_minutes: 20 commands: - pytest -s -v tests/e2e/online_serving/test_longcat_image_edit.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -514,6 +553,9 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Longcat Image Test" + source_file_dependencies: + - tests/e2e/online_serving/test_longcat_image.py + - vllm_omni/diffusion/models/longcat_image/ timeout_in_minutes: 20 commands: - pytest -s -v tests/e2e/online_serving/test_longcat_image.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -552,6 +594,13 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Bagel Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_bagel.py + - tests/e2e/online_serving/test_bagel.py + - vllm_omni/model_executor/models/bagel/ + - vllm_omni/diffusion/models/bagel/ + - vllm_omni/model_executor/stage_input_processors/bagel.py + - vllm_omni/deploy/bagel.yaml timeout_in_minutes: 20 commands: - pytest -s -v tests/e2e/offline_inference/test_bagel.py tests/e2e/online_serving/test_bagel.py -m 'advanced_model and cuda' --run-level 'advanced_model' @@ -591,6 +640,9 @@ steps: - label: "Diffusion · Qwen Image Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image.py + - vllm_omni/diffusion/models/qwen_image/ commands: - | timeout 40m bash -c ' @@ -632,6 +684,9 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Qwen Image Layered Test" + source_file_dependencies: + - tests/e2e/online_serving/test_qwen_image_layered.py + - vllm_omni/diffusion/models/qwen_image/ commands: - | timeout 40m bash -c " @@ -673,6 +728,10 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Wan22 Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_wan22_t2v.py + - tests/e2e/online_serving/test_wan22_t2v.py + - vllm_omni/diffusion/models/wan2_2/ commands: - | timeout 40m bash -c " @@ -714,6 +773,9 @@ steps: type: DirectoryOrCreate - label: "Diffusion · HunyuanVideo 1.5 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_hunyuan_video_15.py + - vllm_omni/diffusion/models/hunyuan_video/ timeout_in_minutes: 40 commands: - | @@ -755,6 +817,10 @@ steps: type: DirectoryOrCreate - label: "Diffusion · Z Image Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_z_image.py + - tests/e2e/online_serving/test_z_image.py + - vllm_omni/diffusion/models/z_image/ timeout_in_minutes: 30 commands: - pytest -s -v tests/e2e/offline_inference/test_z_image.py tests/e2e/online_serving/test_z_image.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' @@ -772,6 +838,10 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "Diffusion · FLUX.2 Klein Test" + source_file_dependencies: + - tests/e2e/offline_inference/test_flux2_klein.py + - tests/e2e/online_serving/test_flux2_klein.py + - vllm_omni/diffusion/models/flux2_klein/ timeout_in_minutes: 30 commands: - pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py tests/e2e/online_serving/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' @@ -789,6 +859,9 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" - label: "Diffusion · SD3 Test" + source_file_dependencies: + - tests/e2e/online_serving/test_sd3.py + - vllm_omni/diffusion/models/sd3/ timeout_in_minutes: 45 commands: - | diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index 8db0dba97..1881b4496 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -309,37 +309,14 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate - - label: "Omni · VoxCPM2 Test" - timeout_in_minutes: 20 - commands: - - | - timeout 20m bash -c " - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/offline_inference/test_voxcpm2.py -m 'core_model' --run-level 'core_model' - " - agents: - queue: "gpu_1_queue" - plugins: - - docker#v5.2.0: - image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT - always-pull: true - propagate-environment: true - shm-size: "8gb" - environment: - - "HF_HOME=/fsx/hf_cache" - - "HF_TOKEN" - volumes: - - "/fsx/hf_cache:/fsx/hf_cache" - - - label: "TTS · VoxCPM2 Online Test" + - label: "TTS · VoxCPM2 Test" timeout_in_minutes: 30 commands: - | timeout 30m bash -c " export VLLM_LOGGING_LEVEL=DEBUG export VLLM_WORKER_MULTIPROC_METHOD=spawn - pytest -s -v tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model and tts' --run-level 'core_model' + pytest -s -v tests/e2e/offline_inference/test_voxcpm2_tts.py tests/e2e/online_serving/test_voxcpm2_tts.py -m 'core_model' --run-level 'core_model' " agents: queue: "gpu_1_queue" diff --git a/tests/e2e/offline_inference/test_voxcpm2.py b/tests/e2e/offline_inference/test_voxcpm2_tts.py similarity index 100% rename from tests/e2e/offline_inference/test_voxcpm2.py rename to tests/e2e/offline_inference/test_voxcpm2_tts.py From f1bf5442085e2517177b269246f05cdf547fea61 Mon Sep 17 00:00:00 2001 From: Yuekai Zhang Date: Wed, 10 Jun 2026 16:42:34 +0800 Subject: [PATCH 47/75] [Perf][TTS] Optimize cosyvoice TTFP and throughput using TensorRT (#4168) Signed-off-by: Yuekai Zhang Co-authored-by: Claude Opus 4.8 (1M context) --- .../test_cosyvoice3_model_helpers.py | 5 + vllm_omni/data_entry_keys.py | 1 + vllm_omni/deploy/cosyvoice3.yaml | 27 +- .../entrypoints/openai/serving_speech.py | 28 +- .../models/cosyvoice3/code2wav_core/cfm.py | 37 +- .../models/cosyvoice3/cosyvoice3.py | 401 +++++++++++++++--- .../models/cosyvoice3/flow_estimator_trt.py | 154 +++++++ .../cosyvoice3/speaker_embedding_trt.py | 157 +++++++ .../model_executor/models/cosyvoice3/utils.py | 38 ++ .../stage_input_processors/cosyvoice3.py | 18 +- .../transformers_utils/configs/cosyvoice3.py | 7 + 11 files changed, 786 insertions(+), 87 deletions(-) create mode 100644 vllm_omni/model_executor/models/cosyvoice3/flow_estimator_trt.py create mode 100644 vllm_omni/model_executor/models/cosyvoice3/speaker_embedding_trt.py diff --git a/tests/model_executor/models/cosyvoice3/test_cosyvoice3_model_helpers.py b/tests/model_executor/models/cosyvoice3/test_cosyvoice3_model_helpers.py index b0afc9592..a2c3142c7 100644 --- a/tests/model_executor/models/cosyvoice3/test_cosyvoice3_model_helpers.py +++ b/tests/model_executor/models/cosyvoice3/test_cosyvoice3_model_helpers.py @@ -84,6 +84,11 @@ def _make_code2wav_model( token_mel_ratio=2 if with_stride_cfg else 0, ) model.code2wav = _DummyCode2Wav(vocab_size=4, num_samples=num_samples, outputs=outputs) + # Short-circuit the lazy TensorRT estimator swap: these tests exercise the + # forward audio logic, not the TRT path. On a GPU CI runner the swap would + # otherwise run and dereference ``self.model_dir`` (only set in __init__, + # which this fixture bypasses via object.__new__). + model._code2wav_trt_done = True model.source_cache_len = 4 model.speech_window = torch.hamming_window(8, periodic=False) model._stream_audio_cache_by_req = {} diff --git a/vllm_omni/data_entry_keys.py b/vllm_omni/data_entry_keys.py index 2584a7242..eec3e5c33 100644 --- a/vllm_omni/data_entry_keys.py +++ b/vllm_omni/data_entry_keys.py @@ -128,6 +128,7 @@ class EmbeddingsStruct(_StructBase): voice: torch.Tensor | None = None speech_feat: torch.Tensor | None = None speech_token: torch.Tensor | None = None + speech_token_len: torch.Tensor | None = None embedding: torch.Tensor | None = None thinker_reply: torch.Tensor | None = None diff --git a/vllm_omni/deploy/cosyvoice3.yaml b/vllm_omni/deploy/cosyvoice3.yaml index 4bfd4ab85..d81cf4a9b 100644 --- a/vllm_omni/deploy/cosyvoice3.yaml +++ b/vllm_omni/deploy/cosyvoice3.yaml @@ -7,9 +7,22 @@ # references stay in the yaml unconditionally; the runtime only activates # them when ``async_chunk: true``. # -# enforce_eager=true everywhere: -# * Stage 0 (talker) — cudagraph not verified on this checkpoint. -# * Stage 1 (code2wav) — CUDA graphs don't work with dynamic conv shapes. +# enforce_eager: +# * Stage 0 (talker) — enforce_eager: false (CUDA graphs enabled). +# * Stage 1 (code2wav) — enforce_eager: true (CUDA graphs don't work with +# dynamic conv shapes). +# +# Streaming continuity is GPU-class sensitive — TUNE PER GPU. +# The defaults below (max_num_seqs: 8, codec_chunk_frames: 15) are tuned for +# H100, where streaming continuity at concurrency 4 stays ~100%. They push +# more audio per scheduler step through the SharedMemoryConnector than a +# slower GPU can keep up with: on H20-3e the same config regressed continuity +# to ~78% at c=4 (vs ~86% with smaller batch/chunk), because code2wav can't +# produce mel chunks fast enough to feed the streaming output → buffer +# underruns → audible gaps. +# 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. async_chunk: true dtype: float32 @@ -21,16 +34,16 @@ connectors: connector_get_sleep_s: 0.01 connector_get_max_wait_first_chunk: 3000 connector_get_max_wait: 300 - codec_chunk_frames: 25 + codec_chunk_frames: 15 codec_left_context_frames: 25 codec_vocab_size: 6561 stages: - stage_id: 0 max_num_batched_tokens: 32768 - max_num_seqs: 1 + max_num_seqs: 8 gpu_memory_utilization: 0.4 - enforce_eager: true + enforce_eager: false trust_remote_code: true enable_prefix_caching: false devices: "0" @@ -49,7 +62,7 @@ stages: - stage_id: 1 max_num_batched_tokens: 32768 - max_num_seqs: 1 + max_num_seqs: 8 gpu_memory_utilization: 0.2 enforce_eager: true trust_remote_code: true diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 1857c6fc8..c5db707d6 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -410,6 +410,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): ) 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 _apply_cosyvoice3_dynamic_tokens. + self._cosyvoice3_tokenizer = None self._is_cosyvoice3 = ( self._tts_stage is not None @@ -2658,16 +2663,21 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): sampling_params_list = copy.deepcopy(sampling_params_list) hf_cfg = self.model_config.hf_config - model_path = self.engine_client.model_config.model - if not os.path.isdir(model_path): - from huggingface_hub import snapshot_download + # Build the Qwen tokenizer once per process (resolving the model dir via + # snapshot_download at most once) and reuse it across requests. + tokenizer = self._cosyvoice3_tokenizer + if tokenizer is None: + model_path = self.engine_client.model_config.model + if not os.path.isdir(model_path): + from huggingface_hub import snapshot_download - model_path = 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, - version=hf_cfg.version, - ) + model_path = 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, + version=hf_cfg.version, + ) + self._cosyvoice3_tokenizer = tokenizer _, text_token_len = extract_text_token( request.input, tokenizer, diff --git a/vllm_omni/model_executor/models/cosyvoice3/code2wav_core/cfm.py b/vllm_omni/model_executor/models/cosyvoice3/code2wav_core/cfm.py index 36ff0d456..78c1ea66f 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/code2wav_core/cfm.py +++ b/vllm_omni/model_executor/models/cosyvoice3/code2wav_core/cfm.py @@ -141,24 +141,37 @@ class ConditionalCFM(BASECFM): if isinstance(self.estimator, torch.nn.Module): return self.estimator(x, mask, mu, t, spks, cond) else: + # TensorRT estimator: bind raw device pointers. The flow runs in + # fp32 but the engine may have fp16 I/O (strongly-typed fp16 engine), + # so cast inputs/output to the engine's dtype at the boundary. Keep + # references to the cast buffers alive until execute completes (a bare + # ``.contiguous().data_ptr()`` could free the temp -> dangling ptr). + io_dtype = getattr(self.estimator, "io_dtype", x.dtype) [estimator, stream], trt_engine = self.estimator.acquire_estimator() # NOTE need to synchronize when switching stream torch.cuda.current_stream().synchronize() with stream: - estimator.set_input_shape("x", (2, 80, x.size(2))) - estimator.set_input_shape("mask", (2, 1, x.size(2))) - estimator.set_input_shape("mu", (2, 80, x.size(2))) + x_e = x.to(io_dtype).contiguous() + mask_e = mask.to(io_dtype).contiguous() + mu_e = mu.to(io_dtype).contiguous() + t_e = t.to(io_dtype).contiguous() + spks_e = spks.to(io_dtype).contiguous() + cond_e = cond.to(io_dtype).contiguous() + out_e = torch.empty_like(x_e) + estimator.set_input_shape("x", (2, 80, x_e.size(2))) + estimator.set_input_shape("mask", (2, 1, x_e.size(2))) + estimator.set_input_shape("mu", (2, 80, x_e.size(2))) estimator.set_input_shape("t", (2,)) estimator.set_input_shape("spks", (2, 80)) - estimator.set_input_shape("cond", (2, 80, x.size(2))) + estimator.set_input_shape("cond", (2, 80, x_e.size(2))) data_ptrs = [ - x.contiguous().data_ptr(), - mask.contiguous().data_ptr(), - mu.contiguous().data_ptr(), - t.contiguous().data_ptr(), - spks.contiguous().data_ptr(), - cond.contiguous().data_ptr(), - x.data_ptr(), + x_e.data_ptr(), + mask_e.data_ptr(), + mu_e.data_ptr(), + t_e.data_ptr(), + spks_e.data_ptr(), + cond_e.data_ptr(), + out_e.data_ptr(), ] for i, j in enumerate(data_ptrs): estimator.set_tensor_address(trt_engine.get_tensor_name(i), j) @@ -166,7 +179,7 @@ class ConditionalCFM(BASECFM): assert estimator.execute_async_v3(torch.cuda.current_stream().cuda_stream) is True torch.cuda.current_stream().synchronize() self.estimator.release_estimator(estimator, stream) - return x + return out_e.to(x.dtype) class CausalConditionalCFM(ConditionalCFM): diff --git a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py index d20312a97..1a387cdcc 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py +++ b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py @@ -3,10 +3,16 @@ import os from collections.abc import Iterable, Mapping, Sequence from functools import partial +from math import gcd from threading import Lock +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 from vllm.config import VllmConfig from vllm.config.multimodal import BaseDummyOptions @@ -32,12 +38,15 @@ from vllm.v1.sample.metadata import SamplingMetadata from vllm.v1.sample.sampler import Sampler from vllm_omni.data_entry_keys import EmbeddingsStruct, OmniPayloadStruct, to_dict, to_struct +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, extract_speech_feat, - extract_speech_token, extract_spk_embedding, + extract_spk_embedding_trt, extract_text_token, + mel_spectrogram, + unpad_prompt_conditioning, ) from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config @@ -45,6 +54,23 @@ from vllm_omni.utils.speaker_cache import get_speaker_cache logger = init_logger(__name__) +# Process-wide cache of per-model mm-processor runtime components (tokenizer, +# feat_extractor, campplus session/engine). The mm processor is re-created per +# request (mm_processor_cache_gb: 0), so this avoids rebuilding them every time. +_RUNTIME_COMPONENTS_CACHE: dict[str, dict] = {} + + +def _cosyvoice3_trt_enabled() -> bool: + """COSYVOICE3_TRT env toggle (default on) for the optional TensorRT paths. + + Gates both the talker speaker-embedding (campplus) engine and the code2wav + flow-decoder estimator engine. Env-var toggle (cf. mimo_audio's + ``MIMO_AUDIO_TOKENIZER_CUDA_GRAPH``) because these run outside the stage + worker, so deploy-yaml ``hf_overrides`` / per-stage ``env`` do not reach + them — export ``COSYVOICE3_TRT=0`` in the launching shell to disable. + """ + return os.environ.get("COSYVOICE3_TRT", "1") not in ("0", "false", "False", "") + class CosyVoice3MultiModalProcessingInfo(BaseProcessingInfo): def get_hf_config(self): @@ -73,39 +99,136 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal if cached_model_dir == model_dir: return - # If model_dir is an HF repo ID (not a local path), resolve to cache - if not os.path.isdir(model_dir): - from huggingface_hub import snapshot_download + # The mm processor is re-created per request (deploy config sets + # ``mm_processor_cache_gb: 0``), so the instance-level guard above never + # hits across requests. Without a process-wide cache, every request + # would re-run ``snapshot_download``, rebuild the Qwen tokenizer and + # create a fresh ONNX campplus session — ~hundreds of ms of pure + # overhead on the TTFP critical path. Build the heavy components once + # per process, keyed by model_dir, and reuse them. + comps = _RUNTIME_COMPONENTS_CACHE.get(model_dir) + if comps is None: + comps = self._build_runtime_components(model_dir, config) + _RUNTIME_COMPONENTS_CACHE[model_dir] = comps + self.tokenizer = comps["tokenizer"] + self.feat_extractor = comps["feat_extractor"] + self.campplus_session = comps["campplus_session"] + self.campplus_trt = comps["campplus_trt"] + self._cached_model_dir = model_dir + self._speaker_cache = get_speaker_cache() + + def _build_runtime_components(self, model_dir: str, config: CosyVoice3Config) -> dict: + """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) - import onnxruntime - - from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer - from vllm_omni.model_executor.models.cosyvoice3.utils import mel_spectrogram - - option = onnxruntime.SessionOptions() - option.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL - option.intra_op_num_threads = 1 - - self.tokenizer = get_qwen_tokenizer( + tokenizer = get_qwen_tokenizer( token_path=os.path.join(model_dir, config.qwen_pretrain_path), skip_special_tokens=config.skip_special_tokens, version=config.version, ) - self.speech_tokenizer = onnxruntime.InferenceSession( - os.path.join(model_dir, config.speech_tokenizer_path), - sess_options=option, - providers=["CUDAExecutionProvider" if torch.cuda.is_available() else "CPUExecutionProvider"], - ) - self.feat_extractor = partial(mel_spectrogram, **getattr(config, "feat_extractor", {})) - self.campplus_session = onnxruntime.InferenceSession( - os.path.join(model_dir, config.campplus_onxx_path), - sess_options=option, - providers=["CPUExecutionProvider"], - ) - self._cached_model_dir = model_dir - self._speaker_cache = get_speaker_cache() + feat_extractor = partial(mel_spectrogram, **getattr(config, "feat_extractor", {})) + + campplus_onnx_path = os.path.join(model_dir, config.campplus_onxx_path) + # TensorRT speaker-embedding path (default on); a prebuilt TRT engine + # runs campplus on GPU. The engine itself is cached process-wide by + # ``get_campplus_trt``. + campplus_trt = None + if self._speaker_embedding_trt_enabled() and torch.cuda.is_available(): + try: + from vllm_omni.model_executor.models.cosyvoice3.speaker_embedding_trt import ( + get_campplus_trt, + ) + + campplus_trt = get_campplus_trt(campplus_onnx_path, device="cuda") + logger.info("CosyVoice3: using TensorRT campplus speaker embedding") + except Exception as exc: # pragma: no cover - defensive fallback + logger.warning( + "CosyVoice3: TensorRT campplus build failed (%s); falling back to ONNX-Runtime speaker embedding", + exc, + ) + campplus_trt = None + + # Only build the CPU ONNX campplus session when TRT is unavailable — + # otherwise it is never used and creating it (ORT_ENABLE_ALL graph + # optimization) costs ~hundreds of ms. + campplus_session = None + if campplus_trt is None: + option = onnxruntime.SessionOptions() + option.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL + option.intra_op_num_threads = 1 + campplus_session = onnxruntime.InferenceSession( + campplus_onnx_path, + sess_options=option, + providers=["CPUExecutionProvider"], + ) + + return { + "tokenizer": tokenizer, + "feat_extractor": feat_extractor, + "campplus_session": campplus_session, + "campplus_trt": campplus_trt, + } + + @staticmethod + def _speaker_embedding_trt_enabled() -> bool: + """Whether to use the TensorRT campplus speaker-embedding path (default on).""" + return _cosyvoice3_trt_enabled() + + # Class-level cached s3tokenizer model — loaded once per process on first + # call to ``_extract_speech_token_via_s3`` and shared across all + # processor instances. + _s3_model = None + + @classmethod + def _ensure_s3_model(cls): + if cls._s3_model is not None: + return cls._s3_model + + # s3tokenizer is imported lazily (kept off the module top level) so + # callers that don't use the CosyVoice3 talker need not install it. + try: + import s3tokenizer as _s3 + except ImportError as e: + raise ImportError( + "CosyVoice3 speech-token extraction requires the 's3tokenizer' " + "package; install it with `pip install s3tokenizer`." + ) from e + + model = _s3.load_model("speech_tokenizer_v3_25hz") + device = "cuda" if torch.cuda.is_available() else "cpu" + model = model.to(device).eval() + cls._s3_model = (model, _s3, device) + return cls._s3_model + + def _extract_speech_token_via_s3(self, audio, return_device): + """Drop-in replacement for ``extract_speech_token`` that uses the + S3Tokenizer PyTorch model on GPU. Returns the same + ``(speech_token[1, T], speech_token_len[1])`` int32 tensors as the + ONNX path so the rest of ``_call_hf_processor`` is unchanged. + """ + model, _s3, dev = self._ensure_s3_model() + + # audio is a (waveform_ndarray, sr) tuple; resample to 16 kHz mono float32. + wav, sr = audio + wav = np.asarray(wav, dtype=np.float32) + if wav.ndim == 2: + wav = wav.mean(axis=1) + if int(sr) != 16000: + g = gcd(int(sr), 16000) + wav = resample_poly(wav, 16000 // g, int(sr) // g).astype(np.float32) + audio_t = torch.from_numpy(wav) + + mel = _s3.log_mel_spectrogram(audio_t) + mels_p, mels_lens = _s3.padding([mel]) + with torch.inference_mode(): + codes, codes_lens = model.quantize(mels_p.to(dev), mels_lens.to(dev)) + n = int(codes_lens[0].item()) + speech_token = codes[:1, :n].to(dtype=torch.int32, device=return_device) + speech_token_len = torch.tensor([n], dtype=torch.int32, device=return_device) + return speech_token, speech_token_len def _call_hf_processor( self, @@ -185,7 +308,11 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal ) return ft - speech_token, speech_token_len = extract_speech_token(audio, self.speech_tokenizer, device) + # Speech-token extraction via the S3Tokenizer PyTorch model on GPU + # (~30x faster than the bundled ``speech_tokenizer_v3.onnx`` CPU ONNX + # path in this venv). + speech_token, speech_token_len = self._extract_speech_token_via_s3(audio, device) + speech_feat, speech_feat_len = extract_speech_feat(audio, self.feat_extractor, device) if config.sample_rate == 24000: @@ -193,7 +320,10 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal speech_feat, speech_feat_len[:] = speech_feat[:, : 2 * token_len], 2 * token_len speech_token, speech_token_len[:] = speech_token[:, :token_len], token_len - embedding = extract_spk_embedding(audio, self.campplus_session, device) + if self.campplus_trt is not None: + embedding = extract_spk_embedding_trt(audio, self.campplus_trt, device) + else: + embedding = extract_spk_embedding(audio, self.campplus_session, device) # Cache the extracted artifacts for named speakers if cache_key is not None: @@ -318,8 +448,6 @@ 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): - from huggingface_hub import snapshot_download - model_dir = snapshot_download(model_dir) self.model_dir = model_dir self.model = None @@ -377,8 +505,6 @@ class CosyVoice3Model( the pretrained model directory. The cache config is inherited from the parent to enable PagedAttention with the same memory configuration. """ - from transformers import Qwen2Config - qwen_config_path = os.path.join(self.model_dir, self.config.llm["llm"]["pretrain_path"]) qwen_hf_config = Qwen2Config.from_pretrained(qwen_config_path) @@ -469,26 +595,32 @@ class CosyVoice3Model( top_k: int, generator: torch.Generator | None, ) -> int: + """Vectorized nucleus + top-k sampling. + + Distribution-equivalent to the reference iterative implementation: the + keep-set is identical (token i is kept iff + ``cumsum(sorted_probs)[i] - sorted_probs[i] < top_p`` AND ``i < top_k``) + and the renormalized sampling distribution matches, but the exact token + drawn for a given seed is NOT guaranteed to match. The reference draws + via ``multinomial`` over the stacked kept subset while this draws over + the full sorted vector (zeroed outside the keep-set), so the generator + advances over different-sized inputs and may yield a different sample. + The win: no per-token ``.item()`` D2H syncs from the Python loop — + those dominated the sampler CPU time in profiling. + """ probs = weighted_scores.softmax(dim=0) sorted_prob, sorted_idx = probs.sort(descending=True, stable=True) - kept_probs: list[torch.Tensor] = [] - kept_indices: list[torch.Tensor] = [] - cum_prob = 0.0 - max_keep = len(sorted_idx) if top_k <= 0 else min(int(top_k), len(sorted_idx)) - for i in range(len(sorted_idx)): - if cum_prob < top_p and len(kept_probs) < max_keep: - cum_prob += float(sorted_prob[i].item()) - kept_probs.append(sorted_prob[i]) - kept_indices.append(sorted_idx[i]) - else: - break - - if not kept_probs: - return int(sorted_idx[0].item()) - - sample_probs = torch.stack(kept_probs) - sample_idx = cls._multinomial_sample(sample_probs, generator=generator) - return int(torch.stack(kept_indices)[int(sample_idx.item())].item()) + cum_before = sorted_prob.cumsum(dim=0) - sorted_prob + mask = cum_before < top_p + if top_k > 0: + n = sorted_prob.shape[0] + mask = mask & (torch.arange(n, device=mask.device) < min(int(top_k), n)) + weights = sorted_prob * mask.to(sorted_prob.dtype) + # First token always passes (cum_before[0] = 0 < top_p for any top_p > 0), + # so ``weights`` is guaranteed to have at least one nonzero entry. The + # final ``.item()`` is the ONLY D2H sync per call. + sample_idx = torch.multinomial(weights, 1, replacement=True, generator=generator) + return int(sorted_idx[sample_idx].item()) @classmethod def _ras_sample_one( @@ -627,11 +759,21 @@ class CosyVoice3Model( else: raise RuntimeError(f"compute_logits is only valid for {self.model_stage}.") - def embed_multimodal(self, **kwargs: object) -> torch.Tensor: + def embed_multimodal(self, **kwargs: object): if self.model_stage == "cosyvoice3_talker": speech_token = kwargs["speech_token"] - speech_token_emb = self.model.speech_embedding(speech_token) - return speech_token_emb + # vLLM's _execute_mm_encoder batches ALL multimodal items scheduled + # in one engine step into a single call, expecting one embedding + # tensor per item back. When >=2 requests prefill in the same step + # (likely once mm preprocessing is fast, e.g. TensorRT speaker + # embedding), speech_token arrives as a list of per-item tensors + # (variable length), so embed it per item and return a list of + # [T_i, emb] tensors. The single-item path keeps returning a + # [1, T, emb] tensor (the caller's extend() iterates dim 0). + if isinstance(speech_token, (list, tuple)): + emb_dim = self.model.speech_embedding.weight.shape[1] + return [self.model.speech_embedding(t).reshape(-1, emb_dim) for t in speech_token] + return self.model.speech_embedding(speech_token) else: raise RuntimeError(f"embed_multimodal is only valid for {self.model_stage}.") @@ -720,6 +862,121 @@ class CosyVoice3Model( else: raise RuntimeError(f"embed_input_ids is not valid for {self.model_stage}.") + @staticmethod + def _split_prompt_conditioning(speech_token, speech_feat, embedding, speech_token_len): + """Split collated prompt conditioning into per-request lists. + + Returns ``(speech_token_list, speech_feat_list, embedding_list, + speech_token_len_list)``, one rank-correct tensor per request: + ``speech_token`` ``[1, T]`` (possibly right-padded), ``speech_feat`` + ``[1, 2T, F]``, ``embedding`` ``[1, D]``, ``speech_token_len`` ``[1, 1]``. + + This runs inside the talker ``forward`` which may be CUDA-graph + captured, so it must NOT trigger any host<->device sync (no ``.item()`` + / ``.tolist()`` / Python-int slicing by tensor value). Splitting is pure + on-device indexing; right-padding is dropped later in the (eager) + code2wav stage using the per-request ``speech_token_len``. Robust to + inputs arriving as padded ``[B, ...]`` batch tensors or per-request + lists. + """ + + def _rows(x, want_dim): + if isinstance(x, (list, tuple)): + items = list(x) + elif isinstance(x, torch.Tensor): + # ``x.shape[0]`` is a static Python int — no device sync. + items = [x[i] for i in range(x.shape[0])] + else: + return None + out = [] + for t in items: + if isinstance(t, torch.Tensor): + while t.dim() < want_dim: + t = t.unsqueeze(0) + if t.shape[0] != 1: + t = t[:1] + t = t.contiguous() + out.append(t) + return out + + st_out = _rows(speech_token, 2) + sf_out = _rows(speech_feat, 3) + emb_out = _rows(embedding, 2) + stl_out = _rows(speech_token_len, 2) + return st_out, sf_out, emb_out, stl_out + + def _resolve_flow_estimator_onnx(self) -> str | None: + """Locate the flow-decoder estimator ONNX for the TensorRT engine. + + Prefers the fp16 (strongly-typed) ONNX → fp16 engine. Order: env + ``COSYVOICE3_ESTIMATOR_ONNX`` → ``/`` → fetched + from ``flow_estimator_onnx_repo`` → bundled fp32 ONNX (fp32+TF32). Each + is checked for existence; returns ``None`` if nothing is found. + """ + env_path = os.environ.get("COSYVOICE3_ESTIMATOR_ONNX") + if env_path and os.path.exists(env_path): + return env_path + + fp16_name = getattr(self.config, "flow_estimator_onnx_path", "flow.decoder.estimator.autocast_fp16.onnx") + local_fp16 = os.path.join(self.model_dir, fp16_name) + if os.path.exists(local_fp16): + return local_fp16 + + repo = getattr(self.config, "flow_estimator_onnx_repo", None) + if repo: + try: + fetched_dir = snapshot_download(repo, allow_patterns=[fp16_name]) + fetched = os.path.join(fetched_dir, fp16_name) + if os.path.exists(fetched): + return fetched + except Exception as exc: # pragma: no cover - network/repo issues + logger.warning("CosyVoice3 code2wav: could not fetch fp16 estimator ONNX from %s (%s)", repo, exc) + + fp32_name = getattr(self.config, "flow_estimator_onnx_path_fp32", "flow.decoder.estimator.fp32.onnx") + local_fp32 = os.path.join(self.model_dir, fp32_name) + if os.path.exists(local_fp32): + logger.info("CosyVoice3 code2wav: fp16 estimator ONNX unavailable, falling back to fp32 (%s)", local_fp32) + return local_fp32 + return None + + def _maybe_enable_code2wav_trt(self) -> None: + """Swap the flow-decoder estimator to a TensorRT engine once (lazy). + + Runs on the first code2wav step — after weights are loaded — so the + torch estimator is fully built first and then dropped. No-op unless + ``COSYVOICE3_TRT`` is on (default), CUDA is available, and the estimator + ONNX ships with the model. Falls back to the torch estimator on any + failure. The upstream ``CausalConditionalCFM.forward_estimator`` detects + the non-``nn.Module`` estimator and drives the TRT engine. + """ + if getattr(self, "_code2wav_trt_done", False): + return + self._code2wav_trt_done = True + if not (_cosyvoice3_trt_enabled() and torch.cuda.is_available()): + return + onnx_path = self._resolve_flow_estimator_onnx() + if onnx_path is None: + logger.warning("CosyVoice3 code2wav: no flow-estimator ONNX available; keeping torch estimator") + return + try: + from vllm_omni.model_executor.models.cosyvoice3.flow_estimator_trt import ( + build_flow_estimator_trt, + ) + + wrapper = build_flow_estimator_trt(onnx_path, device="cuda") + # ``estimator`` is a registered nn.Module submodule; delete it first + # (frees the torch estimator weights) so the TRT wrapper can be set + # as a plain attribute — nn.Module.__setattr__ rejects non-Modules. + decoder = self.code2wav.flow_model.decoder + del decoder.estimator + decoder.estimator = wrapper + logger.info("CosyVoice3: using TensorRT flow-decoder estimator (code2wav)") + except Exception as exc: # pragma: no cover - defensive fallback + logger.warning( + "CosyVoice3 code2wav: TensorRT estimator build failed (%s); keeping torch estimator", + exc, + ) + def forward( self, input_ids: torch.Tensor, @@ -741,18 +998,43 @@ class CosyVoice3Model( if "speech_token" in kwargs: # Prompt conditioning tensors for code2wav: live under # ``embed.*`` per OmniPayloadStruct schema. + # + # vLLM hands these mm fields to forward() as collated, padded + # batch tensors (speech_token [B, maxT], speech_feat + # [B, 2*maxT, F], embedding [B, D]). Emitting them raw makes the + # downstream per-request payload split fragile at batch>1: it + # intermittently de-batches speech_token to 1-D and leaks the + # whole [B, D] embedding to every request, which corrupts voice + # conditioning and crashes code2wav (`prompt_token.shape[1]`). + # Instead, split into an explicit per-request list of unpadded, + # correctly-ranked tensors so ``to_payload_element`` splits them + # deterministically by request index (list[idx]). + speech_token_list, speech_feat_list, embedding_list, speech_token_len_list = ( + self._split_prompt_conditioning( + kwargs.get("speech_token"), + kwargs.get("speech_feat"), + kwargs.get("embedding"), + kwargs.get("speech_token_len"), + ) + ) multimodal_outputs = to_dict( OmniPayloadStruct( embed=EmbeddingsStruct( - speech_token=kwargs.get("speech_token"), - speech_feat=kwargs.get("speech_feat"), - embedding=kwargs.get("embedding"), + speech_token=speech_token_list, + speech_feat=speech_feat_list, + speech_token_len=speech_token_len_list, + embedding=embedding_list, ), ) ) return OmniOutput(text_hidden_states=hidden_states, multimodal_outputs=multimodal_outputs) elif self.model_stage == "cosyvoice3_code2wav": + # Lazily swap the flow-decoder estimator to a TensorRT engine on the + # first code2wav step (after weights are loaded), gated by the same + # COSYVOICE3_TRT env toggle as the talker speaker embedding. + self._maybe_enable_code2wav_trt() + runtime_info = kwargs.get("model_intermediate_buffer") if runtime_info is None: runtime_info = kwargs.get("runtime_additional_information", []) @@ -784,6 +1066,11 @@ class CosyVoice3Model( speech_token = embed.speech_token if embed else None speech_feat = embed.speech_feat if embed else None embedding = embed.embedding if embed else None + # Drop any right-padding carried from batched talker emission. + if speech_token is not None and speech_feat is not None: + speech_token, speech_feat = unpad_prompt_conditioning( + speech_token, speech_feat, embed.speech_token_len if embed else None + ) if speech_token is None or speech_feat is None or embedding is None: if stream_finished and req_id is not None and hasattr(self, "_stream_vocoder_cache_by_req"): with self._stream_audio_cache_lock: diff --git a/vllm_omni/model_executor/models/cosyvoice3/flow_estimator_trt.py b/vllm_omni/model_executor/models/cosyvoice3/flow_estimator_trt.py new file mode 100644 index 000000000..230fb14fb --- /dev/null +++ b/vllm_omni/model_executor/models/cosyvoice3/flow_estimator_trt.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""TensorRT engine for the CosyVoice3 flow-decoder (CFM) DiT estimator. + +The estimator is the per-step network the conditional flow-matching ODE solver +calls during code2wav (token -> mel). It dominates code2wav latency; the +upstream ``CausalConditionalCFM.forward_estimator`` already supports running it +through a TensorRT engine (it switches on ``self.estimator`` not being an +``nn.Module`` and drives it via ``acquire_estimator`` / ``execute_async_v3``). +This module builds that engine from the bundled ``flow.decoder.estimator*.onnx`` +and wraps it so it can be dropped in for the torch estimator. + +The estimator engine has 6 inputs ``x, mask, mu, t, spks, cond`` and one output. +``x/mask/mu/cond`` carry a dynamic time dim; ``t``/``spks`` are fixed, so only +the former need an optimization profile (TRT infers the latter from the ONNX). +Shapes mirror the CosyVoice runtime. + +Precision: TensorRT >= 11 dropped the weakly-typed FP16/INT8 builder flags, so +fp16 only comes from a STRONGLY_TYPED network built from an fp16 ONNX +(``*autocast_fp16*``, fp16 I/O). An fp32 ONNX is built fp32 + the TF32 matmul +flag. ``EXPLICIT_BATCH`` is implicit (no flag) and ``ITensor.dtype`` is +read-only, so neither is set here. +""" + +from __future__ import annotations + +import os +import queue + +import torch +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.cosyvoice3.speaker_embedding_trt import ( + _resolve_plan_path, + _trt_logger, +) + +logger = init_logger(__name__) + +# Optimization-profile shapes for the dynamic-length inputs (CFG batch dim = 2, +# 80 mel channels, time dim min/opt/max). Matches CosyVoice's token2wav. +_DYNAMIC_INPUTS = ("x", "mask", "mu", "cond") +_MIN_SHAPES = ((2, 80, 4), (2, 1, 4), (2, 80, 4), (2, 80, 4)) +_OPT_SHAPES = ((2, 80, 500), (2, 1, 500), (2, 80, 500), (2, 80, 500)) +_MAX_SHAPES = ((2, 80, 3000), (2, 1, 3000), (2, 80, 3000), (2, 80, 3000)) + + +def _is_fp16_onnx(onnx_path: str) -> bool: + """Heuristic: the project's fp16 estimator ONNX is exported strongly-typed + and named ``*autocast_fp16*`` / ``*fp16*`` (vs ``*fp32*``).""" + name = os.path.basename(onnx_path).lower() + return "fp16" in name or "autocast" in name + + +def _convert_onnx_to_trt(onnx_path: str, plan_path: str, strongly_typed: bool) -> None: + import tensorrt as trt + + logger.info( + "Building flow-estimator TensorRT engine from %s (%s) ...", + onnx_path, + "strongly-typed/fp16" if strongly_typed else "fp32+TF32", + ) + trt_logger = _trt_logger() + builder = trt.Builder(trt_logger) + # STRONGLY_TYPED takes precision from the ONNX graph (fp16 engine from an + # fp16 ONNX) — this is the only way to get fp16 in TRT>=11, which dropped + # the weakly-typed FP16 BuilderFlag. Otherwise EXPLICIT_BATCH is implicit, + # so create the network with no flags. + if strongly_typed: + network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.STRONGLY_TYPED)) + else: + network = builder.create_network(0) + parser = trt.OnnxParser(network, trt_logger) + with open(onnx_path, "rb") as f: + if not parser.parse(f.read()): + errs = "; ".join(str(parser.get_error(i)) for i in range(parser.num_errors)) + raise ValueError(f"Failed to parse {onnx_path}: {errs}") + + config = builder.create_builder_config() + config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 32) # 4 GiB + if not strongly_typed: + # fp32 ONNX: enable the best available reduced-precision matmul flag + # (FP16 on older TRT, else TF32 on Ampere+/Hopper). For a strongly-typed + # network the precision is fixed by the graph, so no flag is set. + for _flag_name in ("FP16", "TF32"): + _flag = getattr(trt.BuilderFlag, _flag_name, None) + if _flag is not None: + config.set_flag(_flag) + break + + profile = builder.create_optimization_profile() + for name, mn, op, mx in zip(_DYNAMIC_INPUTS, _MIN_SHAPES, _OPT_SHAPES, _MAX_SHAPES): + profile.set_shape(name, mn, op, mx) + config.add_optimization_profile(profile) + + engine_bytes = builder.build_serialized_network(network, config) + if engine_bytes is None: + raise RuntimeError(f"TensorRT failed to build flow-estimator engine from {onnx_path}") + tmp = plan_path + ".tmp" + with open(tmp, "wb") as f: + f.write(engine_bytes) + os.replace(tmp, plan_path) + logger.info("Wrote flow-estimator TensorRT engine to %s", plan_path) + + +class TrtContextWrapper: + """Pool of TensorRT execution contexts for the flow estimator. + + Exposes the ``acquire_estimator`` / ``release_estimator`` contract that + ``CausalConditionalCFM.forward_estimator`` expects. + """ + + def __init__( + self, engine, device: str | torch.device, io_dtype: torch.dtype = torch.float32, trt_concurrent: int = 1 + ): + self.trt_engine = engine + # Engine I/O dtype (fp16 for a strongly-typed fp16 engine). The flow runs + # in fp32, so forward_estimator casts to/from this at the boundary. + self.io_dtype = io_dtype + self._pool: queue.Queue = queue.Queue(maxsize=trt_concurrent) + for _ in range(trt_concurrent): + ctx = engine.create_execution_context() + assert ctx is not None, "failed to create TRT execution context (out of memory?)" + stream = torch.cuda.stream(torch.cuda.Stream(torch.device(device))) + self._pool.put([ctx, stream]) + + def acquire_estimator(self): + return self._pool.get(), self.trt_engine + + def release_estimator(self, context, stream): + self._pool.put([context, stream]) + + +def build_flow_estimator_trt(onnx_path: str, device: str | torch.device) -> TrtContextWrapper: + """Build/load the flow-estimator TRT engine and return a context-pool wrapper. + + An fp16 ONNX (``*autocast_fp16*``) is built as a strongly-typed network (the + only way to get fp16 in TRT>=11); an fp32 ONNX is built fp32 + TF32. + """ + import tensorrt as trt + + strongly_typed = _is_fp16_onnx(onnx_path) + plan_path = _resolve_plan_path(onnx_path, prefix="flow_estimator") + if not os.path.exists(plan_path) or os.path.getsize(plan_path) == 0: + _convert_onnx_to_trt(onnx_path, plan_path, strongly_typed=strongly_typed) + + runtime = trt.Runtime(_trt_logger()) + with open(plan_path, "rb") as f: + engine = runtime.deserialize_cuda_engine(f.read()) + if engine is None: + raise RuntimeError(f"Failed to deserialize flow-estimator TensorRT engine {plan_path}") + logger.info("Loaded flow-estimator TensorRT engine (%s)", plan_path) + io_dtype = torch.float16 if strongly_typed else torch.float32 + return TrtContextWrapper(engine, device=device, io_dtype=io_dtype) diff --git a/vllm_omni/model_executor/models/cosyvoice3/speaker_embedding_trt.py b/vllm_omni/model_executor/models/cosyvoice3/speaker_embedding_trt.py new file mode 100644 index 000000000..61aacd917 --- /dev/null +++ b/vllm_omni/model_executor/models/cosyvoice3/speaker_embedding_trt.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""TensorRT speaker-embedding (campplus) extractor for CosyVoice3. + +Drop-in accelerator for the ONNX-Runtime campplus session used in the +multimodal processor. ``campplus.onnx`` maps an fbank feature +``[B, T, 80]`` to a 192-d speaker embedding ``[B, 192]``; on this stack the +ONNX path only has a CPU execution provider, so it dominates per-request +prompt-processing latency. Building a TensorRT engine once and running it on +GPU removes that bottleneck. + +The engine is built lazily from ``campplus.onnx`` on first use and cached as +a ``.plan`` file (engines are device/TRT-version specific, so the cache key +includes both). Set ``COSYVOICE3_TRT_CACHE`` to override the cache dir. + +Written against TensorRT >= 10/11: ``EXPLICIT_BATCH`` is the implicit default +(create the network with no flags) and ``ITensor.dtype`` is read-only (I/O +dtypes come from the ONNX graph), so neither is set here. +""" + +from __future__ import annotations + +import hashlib +import os +import threading + +import torch +from vllm.logger import init_logger + +logger = init_logger(__name__) + +# campplus fbank sequence length bounds for the optimization profile. +_MIN_T = 4 +_OPT_T = 500 +_MAX_T = 3000 +_EMBED_DIM = 192 + + +def _trt_logger(): + import tensorrt as trt + + return trt.Logger(trt.Logger.WARNING) + + +def _convert_onnx_to_trt(onnx_path: str, plan_path: str) -> None: + import tensorrt as trt + + logger.info("Building campplus TensorRT engine from %s ...", onnx_path) + trt_logger = _trt_logger() + builder = trt.Builder(trt_logger) + # EXPLICIT_BATCH is implicit in TRT>=10; create the network with no flags. + network = builder.create_network(0) + parser = trt.OnnxParser(network, trt_logger) + with open(onnx_path, "rb") as f: + if not parser.parse(f.read()): + errs = "; ".join(str(parser.get_error(i)) for i in range(parser.num_errors)) + raise ValueError(f"Failed to parse {onnx_path}: {errs}") + + config = builder.create_builder_config() + config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 31) # 2 GiB + + input_name = network.get_input(0).name + profile = builder.create_optimization_profile() + profile.set_shape( + input_name, + (1, _MIN_T, 80), + (1, _OPT_T, 80), + (1, _MAX_T, 80), + ) + config.add_optimization_profile(profile) + + engine_bytes = builder.build_serialized_network(network, config) + if engine_bytes is None: + raise RuntimeError(f"TensorRT failed to build campplus engine from {onnx_path}") + tmp = plan_path + ".tmp" + with open(tmp, "wb") as f: + f.write(engine_bytes) + os.replace(tmp, plan_path) + logger.info("Wrote campplus TensorRT engine to %s", plan_path) + + +def _resolve_plan_path(onnx_path: str, prefix: str = "campplus") -> str: + cache_dir = os.environ.get("COSYVOICE3_TRT_CACHE") or os.path.join( + os.path.expanduser("~"), ".cache", "vllm_omni", "cosyvoice3_trt" + ) + os.makedirs(cache_dir, exist_ok=True) + try: + # No-arg form targets the current device (avoids the banned + # torch.cuda.current_device per ruff TID251). + dev_name = torch.cuda.get_device_name() + except Exception: + dev_name = "unknown" + import tensorrt as trt + + st = os.stat(onnx_path) + key = f"{os.path.abspath(onnx_path)}|{st.st_size}|{int(st.st_mtime)}|{dev_name}|trt{trt.__version__}" + digest = hashlib.sha1(key.encode()).hexdigest()[:16] + return os.path.join(cache_dir, f"{prefix}_{digest}.plan") + + +class CampplusTRT: + """Lazily-built TensorRT campplus speaker-embedding extractor.""" + + def __init__(self, onnx_path: str, device: str | torch.device): + import tensorrt as trt + + self.device = torch.device(device) + plan_path = _resolve_plan_path(onnx_path) + if not os.path.exists(plan_path) or os.path.getsize(plan_path) == 0: + _convert_onnx_to_trt(onnx_path, plan_path) + + runtime = trt.Runtime(_trt_logger()) + with open(plan_path, "rb") as f: + self.engine = runtime.deserialize_cuda_engine(f.read()) + if self.engine is None: + raise RuntimeError(f"Failed to deserialize campplus TensorRT engine {plan_path}") + self.context = self.engine.create_execution_context() + self.input_name = self.engine.get_tensor_name(0) + self.output_name = self.engine.get_tensor_name(1) + # TRT execution contexts are not safe for concurrent enqueue; serialize. + self._lock = threading.Lock() + logger.info("Loaded campplus TensorRT engine (%s)", plan_path) + + @torch.inference_mode() + def __call__(self, feat: torch.Tensor) -> torch.Tensor: + """feat: ``[T, 80]`` fbank features. Returns ``[1, 192]`` embedding.""" + x = feat.unsqueeze(0).to(self.device, dtype=torch.float32).contiguous() + out = torch.empty((x.shape[0], _EMBED_DIM), device=self.device, dtype=torch.float32) + stream = torch.cuda.current_stream(self.device) + with self._lock: + self.context.set_input_shape(self.input_name, tuple(x.shape)) + self.context.set_tensor_address(self.input_name, x.data_ptr()) + self.context.set_tensor_address(self.output_name, out.data_ptr()) + ok = self.context.execute_async_v3(stream.cuda_stream) + if not ok: + raise RuntimeError("campplus TensorRT execute_async_v3 failed") + stream.synchronize() + return out + + +# Process-wide cache: the mm processor is re-created per request (the deploy +# config disables the mm-processor object cache), so building a fresh +# CampplusTRT each time would re-deserialize the ~33 MB engine and allocate a +# new execution context on every request — inflating TTFP. Cache by +# (engine path, device) and reuse; the context serializes concurrent enqueues +# via its own lock, so a single shared instance is safe across requests. +_CAMPPLUS_CACHE: dict[tuple[str, str], CampplusTRT] = {} + + +def get_campplus_trt(onnx_path: str, device: str | torch.device) -> CampplusTRT: + """Return a process-wide cached :class:`CampplusTRT` (engine + context).""" + key = (os.path.abspath(onnx_path), str(device)) + inst = _CAMPPLUS_CACHE.get(key) + if inst is None: + inst = CampplusTRT(onnx_path, device) + _CAMPPLUS_CACHE[key] = inst + return inst diff --git a/vllm_omni/model_executor/models/cosyvoice3/utils.py b/vllm_omni/model_executor/models/cosyvoice3/utils.py index 0bf0cccb1..5827e6bbb 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/utils.py +++ b/vllm_omni/model_executor/models/cosyvoice3/utils.py @@ -215,6 +215,44 @@ def extract_spk_embedding(prompt_wav, campplus_session, device): return embedding +def unpad_prompt_conditioning(speech_token, speech_feat, speech_token_len): + """Drop right-padding from per-request prompt conditioning. + + The talker emits ``speech_token`` ``[1, T]`` possibly right-padded to the + batch max, plus the true ``speech_token_len``; ``speech_feat`` is at the 2:1 + mel:token ratio (``[1, 2T, F]``). This trims both to the real length. Called + in the (eager) code2wav stage where a host read of the length is allowed. + Returns ``(speech_token, speech_feat)`` unchanged when length is unavailable. + """ + if not isinstance(speech_token, torch.Tensor) or speech_token_len is None: + return speech_token, speech_feat + try: + token_len = int(torch.as_tensor(speech_token_len).reshape(-1)[0].item()) + except (RuntimeError, ValueError, IndexError): + return speech_token, speech_feat + if token_len <= 0 or speech_token.dim() < 2: + return speech_token, speech_feat + token_len = min(token_len, int(speech_token.shape[1])) + speech_token = speech_token[:, :token_len] + if isinstance(speech_feat, torch.Tensor) and speech_feat.dim() >= 2: + speech_feat = speech_feat[:, : 2 * token_len] + return speech_token, speech_feat + + +def extract_spk_embedding_trt(prompt_wav, campplus_trt, device): + """TensorRT counterpart of ``extract_spk_embedding``. + + Identical fbank front-end; the campplus forward runs on a prebuilt + TensorRT engine (GPU) instead of the CPU ONNX-Runtime session. Returns the + same ``[1, 192]`` embedding tensor on ``device``. + """ + speech = load_wav(prompt_wav, 16000) + feat = kaldi.fbank(speech, num_mel_bins=80, dither=0, sample_frequency=16000) + feat = feat - feat.mean(dim=0, keepdim=True) + embedding = campplus_trt(feat) + return embedding.to(device) + + def extract_text_token(text, tokenizer, allowed_special): text_token = tokenizer.encode(text, allowed_special=allowed_special) text_token = torch.tensor([text_token], dtype=torch.int32) diff --git a/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py b/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py index 4c7245e77..2a0fdd869 100644 --- a/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py +++ b/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py @@ -16,6 +16,7 @@ from vllm_omni.data_entry_keys import ( OmniPayloadStruct, ) from vllm_omni.inputs.data import OmniTokensPrompt +from vllm_omni.model_executor.models.cosyvoice3.utils import unpad_prompt_conditioning logger = init_logger(__name__) @@ -192,18 +193,31 @@ def talker2code2wav_async_chunk( info = _decode_additional_information(getattr(request, "additional_information", None)) info_embed = info.get("embed", {}) if isinstance(info, dict) else {} prompt_payload = {} - for key in ("speech_token", "speech_feat", "embedding"): + cond_keys = ("speech_token", "speech_feat", "embedding", "speech_token_len") + for key in cond_keys: value = _to_cpu_tensor(info_embed.get(key)) if value is not None: prompt_payload[key] = value if isinstance(pooling_output, dict): po_embed = pooling_output.get("embed", {}) if isinstance(pooling_output.get("embed"), dict) else {} - for key in ("speech_token", "speech_feat", "embedding"): + for key in cond_keys: if key in prompt_payload: continue value = _to_cpu_tensor(po_embed.get(key)) if value is not None: prompt_payload[key] = value + # Drop any right-padding carried from batched talker emission so + # the chunk-routing math (prompt_token_len) and the conditioning + # sent to code2wav use the true prompt length. + if "speech_token" in prompt_payload: + st_unpad, sf_unpad = unpad_prompt_conditioning( + prompt_payload.get("speech_token"), + prompt_payload.get("speech_feat"), + prompt_payload.pop("speech_token_len", None), + ) + prompt_payload["speech_token"] = st_unpad + if sf_unpad is not None: + prompt_payload["speech_feat"] = sf_unpad prompt_token = prompt_payload.get("speech_token") prompt_token_len = ( int(prompt_token.shape[1]) diff --git a/vllm_omni/transformers_utils/configs/cosyvoice3.py b/vllm_omni/transformers_utils/configs/cosyvoice3.py index 9accc3cc8..b08cec063 100644 --- a/vllm_omni/transformers_utils/configs/cosyvoice3.py +++ b/vllm_omni/transformers_utils/configs/cosyvoice3.py @@ -42,6 +42,13 @@ class CosyVoice3Config(PretrainedConfig): self.qwen_pretrain_path = "CosyVoice-BlankEN" self.campplus_onxx_path = "campplus.onnx" self.speech_tokenizer_path = "speech_tokenizer_v3.onnx" + # Flow-decoder estimator ONNX for the optional TensorRT code2wav path. + # The fp16 (strongly-typed) ONNX gives an fp16 engine; if it is not in + # the model dir it is fetched from ``flow_estimator_onnx_repo``. Falls + # back to the bundled fp32 ONNX (fp32+TF32 engine) when unavailable. + self.flow_estimator_onnx_path = "flow.decoder.estimator.autocast_fp16.onnx" + self.flow_estimator_onnx_repo = "yuekai/Fun-CosyVoice3-0.5B-2512-FP16-ONNX" + self.flow_estimator_onnx_path_fp32 = "flow.decoder.estimator.fp32.onnx" self.spk2info_path = "spk2info.pt" self.version = "cosyvoice3" self.llm = { From d50bf1aa425fbd329aab15a745314f1963300e8f Mon Sep 17 00:00:00 2001 From: Optance Date: Wed, 10 Jun 2026 17:11:01 +0800 Subject: [PATCH 48/75] [Bugfix] Fix Fish Speech prefix cache collision from missing cache_salt (#4008) Signed-off-by: nagelanping --- vllm_omni/entrypoints/openai/serving_speech.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index c5db707d6..d24181a0f 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -2879,6 +2879,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio ) tts_params = {} + # Fish Speech resolves conditioning entirely from raw request fields + # (text / ref_audio / ref_text rebuilt into inputs_embeds); unlike + # uploaded-voice models it has no resolved tts_params to fold in, so + # the salt is derived from the request alone. + prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params) elif self._tts_model_type == "omnivoice": if not request.input or not request.input.strip(): raise ValueError("Input text cannot be empty") From 2d9e5e05159104754293afbcc9d565cea5f082f9 Mon Sep 17 00:00:00 2001 From: zhumingjue138 Date: Wed, 10 Jun 2026 18:29:33 +0800 Subject: [PATCH 49/75] [Test] skip oom test case for issue #4285 (#4311) Signed-off-by: zhumingjue --- tests/dfx/reliability/test_reliability_qwen3_omni.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/dfx/reliability/test_reliability_qwen3_omni.py b/tests/dfx/reliability/test_reliability_qwen3_omni.py index 49028a03f..28e8dedf7 100644 --- a/tests/dfx/reliability/test_reliability_qwen3_omni.py +++ b/tests/dfx/reliability/test_reliability_qwen3_omni.py @@ -319,6 +319,7 @@ QWEN_PARAMS = create_reliability_omni_server_params(RELIABILITY_SCENARIOS, DEPLO @pytest.mark.slow +@pytest.mark.skip(reason="issue#4285") @hardware_test(res={"cuda": "H100"}, num_cards=2) @pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True) def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech( @@ -387,6 +388,7 @@ def test_reliability_fault_gpu_oom_error_contract_consistent_chat_speech( @pytest.mark.slow +@pytest.mark.skip(reason="issue#4285") @hardware_test(res={"cuda": "H100"}, num_cards=2) @pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True) def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_function, openai_client_function) -> None: @@ -429,6 +431,7 @@ def test_reliability_fault_gpu_oom_chat_large_payload_failure(omni_server_functi @pytest.mark.slow +@pytest.mark.skip(reason="issue#4285") @hardware_test(res={"cuda": "H100"}, num_cards=2) @pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True) def test_reliability_fault_gpu_oom_concurrent_pressure_failure(omni_server_function, openai_client_function) -> None: @@ -771,7 +774,7 @@ def test_reliability_fault_process_kill_tree_with_load_fast_fail_and_cleanup( @pytest.mark.slow -@pytest.mark.skip(reason="OOM injection causes the serving instance to exit") +@pytest.mark.skip(reason="issue#4285") @hardware_test(res={"cuda": "H100"}, num_cards=2) @pytest.mark.parametrize("omni_server_function", QWEN_PARAMS, indirect=True) def test_reliability_fault_gpu_oom_state_converges_after_fault_removed( From 39ae6e12f82a296e2d0dd999f0354522ea486bd4 Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Wed, 10 Jun 2026 19:40:50 +0800 Subject: [PATCH 50/75] [Docs] Update README supported models section with TTS and Diffusion categories (#4300) Signed-off-by: hsliu_ustc Co-authored-by: hsliu_ustc Co-authored-by: Claude Opus 4.7 --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9df4508c9..cf1658b45 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,9 @@ vLLM-Omni is flexible and easy to use with: vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, including: -- Omni-modality models (e.g. Qwen-Omni) -- Multi-modality generation models (e.g. Qwen-Image) +- **Omni-modality models** (e.g. Qwen3-Omni, Cosmos, HunyuanImage, BAGEL) +- **TTS models** (e.g. Qwen3-TTS, VoxCPM2, Ming-Omni-TTS, CosyVoice3) +- **Diffusion models** — image, video, and audio generation (e.g. Qwen-Image, Wan2.2, FLUX) ## Getting Started From 2fa4c7a4a9e0693c49e27f5eb0fc2b7af2f1ae53 Mon Sep 17 00:00:00 2001 From: akshatvishu <33392262+akshatvishu@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:41:04 +0530 Subject: [PATCH 51/75] [Model] Add Ming-omni-tts dense 0.5B pipeline (#2906) Signed-off-by: akshatvishu Co-authored-by: Hongsheng Liu --- .buildkite/test-merge.yml | 21 + docs/models/supported_models.md | 1 + .../text_to_speech/README.md | 41 ++ .../text_to_speech/ming_tts/README.md | 248 +++++++++++ .../text_to_speech/ming_tts/cases.yaml | 110 +++++ .../text_to_speech/ming_tts/end2end.py | 200 +++++++++ .../text_to_speech/ming_tts/runner.py | 277 ++++++++++++ .../online_serving/text_to_speech/README.md | 46 ++ .../text_to_speech/ming_tts/README.md | 113 +++++ .../ming_tts/openai_speech_client.py | 225 ++++++++++ .../text_to_speech/ming_tts/run_curl.sh | 76 ++++ .../text_to_speech/ming_tts/run_server.sh | 25 ++ recipes/README.md | 1 + recipes/inclusionAI/Ming-omni-tts-0.5B.md | 82 ++++ .../test_ming_flash_omni_expansion.py | 14 +- tests/e2e/offline_inference/test_ming_tts.py | 295 +++++++++++++ tests/e2e/online_serving/test_ming_tts.py | 92 ++++ .../ming_tts/test_ming_shared_modules.py | 185 ++++++++ .../test_qwen3_omni_streaming_helpers.py | 18 +- vllm_omni/config/pipeline_registry.py | 4 + vllm_omni/config/stage_config.py | 13 + vllm_omni/deploy/ming_tts.yaml | 55 +++ vllm_omni/engine/arg_utils.py | 2 + .../entrypoints/openai/protocol/audio.py | 66 ++- .../entrypoints/openai/serving_speech.py | 342 ++++++++++++++- .../models/common/ming/__init__.py | 2 + .../models/common/ming/audio_dsp.py | 209 +++++++++ .../ming}/audio_vae.py | 363 ++++++++------- .../model_executor/models/common/ming/dit.py | 193 ++++++++ .../model_executor/models/common/ming/fm.py | 68 +++ .../ming_flash_omni/ming_flash_omni_talker.py | 2 +- .../ming_flash_omni_thinker.py | 3 + .../models/ming_flash_omni/prompt_utils.py | 8 +- .../models/ming_flash_omni/talker_module.py | 246 +---------- .../models/ming_flash_omni/voice_presets.py | 3 +- .../models/ming_tts/__init__.py | 13 + .../models/ming_tts/aggregator.py | 67 +++ .../models/ming_tts/audio_prep.py | 261 +++++++++++ .../models/ming_tts/config_ming_tts.py | 209 +++++++++ .../models/ming_tts/constants.py | 67 +++ .../models/ming_tts/flowloss_head.py | 63 +++ .../models/ming_tts/fm/__init__.py | 2 + .../model_executor/models/ming_tts/fm/dit.py | 98 +++++ .../models/ming_tts/ming_tts.py | 269 ++++++++++++ .../models/ming_tts/ming_tts_audio_vae.py | 389 ++++++++++++++++ .../models/ming_tts/ming_tts_llm.py | 414 ++++++++++++++++++ .../models/ming_tts/patch_emission.py | 183 ++++++++ .../models/ming_tts/pipeline.py | 54 +++ .../models/ming_tts/prompt_assembly.py | 274 ++++++++++++ .../models/ming_tts/prompt_encoder.py | 174 ++++++++ .../models/ming_tts/speaker_extractor.py | 51 +++ .../models/ming_tts/validation.py | 172 ++++++++ vllm_omni/model_executor/models/registry.py | 16 + .../stage_input_processors/ming_flash_omni.py | 91 +--- .../stage_input_processors/ming_tts.py | 296 +++++++++++++ 55 files changed, 6263 insertions(+), 549 deletions(-) create mode 100644 examples/offline_inference/text_to_speech/ming_tts/README.md create mode 100644 examples/offline_inference/text_to_speech/ming_tts/cases.yaml create mode 100644 examples/offline_inference/text_to_speech/ming_tts/end2end.py create mode 100644 examples/offline_inference/text_to_speech/ming_tts/runner.py create mode 100644 examples/online_serving/text_to_speech/ming_tts/README.md create mode 100644 examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py create mode 100755 examples/online_serving/text_to_speech/ming_tts/run_curl.sh create mode 100755 examples/online_serving/text_to_speech/ming_tts/run_server.sh create mode 100644 recipes/inclusionAI/Ming-omni-tts-0.5B.md create mode 100644 tests/e2e/offline_inference/test_ming_tts.py create mode 100644 tests/e2e/online_serving/test_ming_tts.py create mode 100644 tests/model_executor/models/ming_tts/test_ming_shared_modules.py create mode 100644 vllm_omni/deploy/ming_tts.yaml create mode 100644 vllm_omni/model_executor/models/common/ming/__init__.py create mode 100644 vllm_omni/model_executor/models/common/ming/audio_dsp.py rename vllm_omni/model_executor/models/{ming_flash_omni => common/ming}/audio_vae.py (64%) create mode 100644 vllm_omni/model_executor/models/common/ming/dit.py create mode 100644 vllm_omni/model_executor/models/common/ming/fm.py create mode 100644 vllm_omni/model_executor/models/ming_tts/__init__.py create mode 100644 vllm_omni/model_executor/models/ming_tts/aggregator.py create mode 100644 vllm_omni/model_executor/models/ming_tts/audio_prep.py create mode 100644 vllm_omni/model_executor/models/ming_tts/config_ming_tts.py create mode 100644 vllm_omni/model_executor/models/ming_tts/constants.py create mode 100644 vllm_omni/model_executor/models/ming_tts/flowloss_head.py create mode 100644 vllm_omni/model_executor/models/ming_tts/fm/__init__.py create mode 100644 vllm_omni/model_executor/models/ming_tts/fm/dit.py create mode 100644 vllm_omni/model_executor/models/ming_tts/ming_tts.py create mode 100644 vllm_omni/model_executor/models/ming_tts/ming_tts_audio_vae.py create mode 100644 vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py create mode 100644 vllm_omni/model_executor/models/ming_tts/patch_emission.py create mode 100644 vllm_omni/model_executor/models/ming_tts/pipeline.py create mode 100644 vllm_omni/model_executor/models/ming_tts/prompt_assembly.py create mode 100644 vllm_omni/model_executor/models/ming_tts/prompt_encoder.py create mode 100644 vllm_omni/model_executor/models/ming_tts/speaker_extractor.py create mode 100644 vllm_omni/model_executor/models/ming_tts/validation.py create mode 100644 vllm_omni/model_executor/stage_input_processors/ming_tts.py diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml index 410695f4b..1dd5caa66 100644 --- a/.buildkite/test-merge.yml +++ b/.buildkite/test-merge.yml @@ -228,6 +228,27 @@ steps: volumes: - "/fsx/hf_cache:/fsx/hf_cache" + - label: "TTS · Ming-Omni-TTS Test" + timeout_in_minutes: 20 + commands: + - | + timeout 20m bash -c " + export VLLM_LOGGING_LEVEL=DEBUG + pytest -s -v tests/e2e/online_serving/test_ming_tts.py tests/e2e/offline_inference/test_ming_tts.py -m 'advanced_model and cuda' --run-level 'advanced_model' + " + agents: + queue: "gpu_1_queue" + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + shm-size: "8gb" + environment: + - "HF_HOME=/fsx/hf_cache" + volumes: + - "/fsx/hf_cache:/fsx/hf_cache" + - label: "TTS · Voxtral-TTS Test" source_file_dependencies: - tests/e2e/offline_inference/test_voxtral_tts.py diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index c47b6a503..49e837772 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -63,6 +63,7 @@ th { | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | +| `MingTTSForConditionalGeneration` | Ming-omni-tts dense 0.5B | `inclusionAI/Ming-omni-tts-0.5B` | ✅︎ | | | | | `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | | | `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | | | | `MossTTSDelayModel` | MOSS-TTS, MOSS-TTSD, MOSS-SoundEffect, MOSS-VoiceGenerator | `OpenMOSS-Team/MOSS-VoiceGenerator` | ✅︎ | | | | diff --git a/examples/offline_inference/text_to_speech/README.md b/examples/offline_inference/text_to_speech/README.md index a0e9b529b..15672b94f 100644 --- a/examples/offline_inference/text_to_speech/README.md +++ b/examples/offline_inference/text_to_speech/README.md @@ -17,6 +17,7 @@ list of supported architectures across all modalities, see | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz | | Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz | | GLM-TTS | `zai-org/GLM-TTS` | 2 (AR + DiT) | ✓ (required) | ✓ | — | 24 kHz | +| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | 2 (AR + audio VAE) | ✓ | ✓ | style / IP / dialect / TTA / podcast | 44.1 kHz | | Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | single (talker only) | — (caption-controlled) | — | style / IP / basic captions | 44.1 kHz | | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | single (AR + codec) | ✓ (required) | ✓ | voice_clone, continuation | 48 kHz | | OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz | @@ -159,6 +160,46 @@ Streaming requires `async_chunk: true` in the stage config. --- +## Ming-omni-tts + +Dense 0.5B two-stage TTS pipeline (`AR + flow` + audio VAE) at 44.1 kHz. The example covers style, IP voice, music-only generation, text-to-audio events, emotion, dialect, zero-shot cloning, podcast, speech+BGM, and speech+environment-sound cases. + +### Quick start +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case style \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +### Voice cloning +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case zero_shot \ + --ref-audio /path/to/reference.wav \ + --ref-text "在此奉劝大家别乱打美白针。" \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +### Streaming +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case basic \ + --ref-audio /path/to/reference.wav \ + --streaming \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +### Notes +- `style`, `ip`, `bgm`, and `tta` do not require reference audio. +- Reference-audio cases use `--ref-audio`; `zero_shot` also requires `--ref-text`. +- `podcast` uses multiple references via `--ref-audio-paths`. +- Full case details live in [`ming_tts/README.md`](ming_tts/README.md). + +--- + ## Ming-flash-omni-TTS Standalone talker-only deployment of Ming-flash-omni-2.0 at 44.1 kHz. Voice is controlled through caption fields (`风格` / `IP` / `语速`/`基频`/`音量`) rather than reference audio. diff --git a/examples/offline_inference/text_to_speech/ming_tts/README.md b/examples/offline_inference/text_to_speech/ming_tts/README.md new file mode 100644 index 000000000..3fdfaf4d5 --- /dev/null +++ b/examples/offline_inference/text_to_speech/ming_tts/README.md @@ -0,0 +1,248 @@ +# Ming-omni-tts Offline Inference + +`end2end.py` runs Ming dense 0.5B end to end with vLLM-Omni. It uses the in-repo Ming prompt assembly helper directly, so the example request shape matches the real integration instead of a simplified wrapper. + +## Files + +| File | Purpose | +|---|---| +| `end2end.py` | Driver: CLI, case loading, prompt construction, orchestration (~150 lines) | +| `cases.yaml` | All 11 built-in case definitions (prompt, text, instruction, ref-audio flags, flow controls) | +| `runner.py` | Engine management and audio output (streaming + blocking paths) | + +## Model Overview + +Ming dense 0.5B is exposed here as a two-stage offline pipeline: + +- **Stage 0**: Qwen2-based AR generation with Ming prompt formatting and inline flow controls +- **Stage 1**: audio VAE decode to mono 44.1 kHz waveform + +`config_ming_tts.py` adapts the checkpoint's HuggingFace config fields +(LLM, DiT, aggregator, AudioVAE, token ids). `vllm_omni/deploy/ming_tts.yaml` +selects the vLLM-Omni pipeline and stage runtime topology, including +connectors, async chunking, memory limits, and sampling defaults. + +The example supports both: + +- **Blocking eager** via `vllm_omni/deploy/ming_tts.yaml` +- **Async chunk eager** via `vllm_omni/deploy/ming_tts.yaml` (default `async_chunk: true`) + +## Setup + +Install vLLM-Omni with the platform requirements for your accelerator: + +```bash +uv pip install -e . +``` + +The Ming offline example does not require a separate upstream Ming package. +Reference-audio cases use the repo dependencies for audio loading, +resampling, and CampPlus speaker extraction, including `soundfile`, +`torchaudio`, and `onnxruntime-rocm` in the documented ROCm environment. + +The tested ROCm environment is summarized in the +[Ming recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/inclusionAI/Ming-omni-tts-0.5B.md). + +## Supported Cases + +These cases cover the upstream dense 0.5B cookbook surface that maps cleanly onto the current vLLM-Omni example: + +- `style`: zero-speaker style-conditioned speech +- `ip`: zero-speaker IP voice generation +- `bgm`: music-only generation +- `tta`: text-to-audio event generation with FlowLoss controls +- `emotion`: reference-audio speech with emotion control +- `basic`: reference-audio speech with speed / pitch / volume control +- `dialect`: reference-audio speech with dialect control +- `zero_shot`: reference-audio cloning with explicit transcript +- `podcast`: multi-reference dialogue generation with automatic speaker embedding extraction +- `speech_bgm`: speech with background music conditioning +- `speech_sound`: speech with environmental sound conditioning + +## Quick Start + +Run the zero-speaker style example: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case style \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +Run zero-shot cloning with a transcript: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case zero_shot \ + --ref-audio /path/to/10002287-00000094.wav \ + --ref-text "在此奉劝大家别乱打美白针。" \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +Run emotion-controlled speech: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case emotion \ + --ref-audio /path/to/emotion_prompt.wav \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +Run podcast generation with two reference clips: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case podcast \ + --ref-audio-paths /path/to/CTS-CN-F2F-2019-11-11-423-012-A.wav /path/to/CTS-CN-F2F-2019-11-11-423-012-B.wav \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +The script automatically extracts one 192-d speaker embedding per reference WAV using the Ming model's `campplus.onnx`. + +If you already have precomputed multi-speaker embeddings, you can override extraction with: + +```bash +--speaker-embedding /path/to/podcast_speaker_embeddings.json +``` + +where the JSON is a list of speaker embeddings, one 192-d vector per speaker. + +Run text-to-audio event generation: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case tta \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +Use async_chunk streaming: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case basic \ + --ref-audio /path/to/10002287-00000095.wav \ + --streaming \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager +``` + +`--streaming` uses `AsyncOmni` and the async_chunk stage config. It currently +supports one prompt per process invocation; use blocking mode for +`--num-prompts > 1`. + +Collect runtime stats and a manifest: + +```bash +python examples/offline_inference/text_to_speech/ming_tts/end2end.py \ + --case style \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --enforce-eager \ + --enable-stats \ + --stats-log-file output_audio/ming_style_pipeline.log \ + --metadata-json output_audio/ming_style_manifest.json +``` + +## Reference Fixtures + +The upstream Ming cookbook uses these public audio fixtures from `inclusionAI/Ming-omni-tts/data/wavs`: + +- `10002287-00000094.wav` for zero-shot cloning +- `10002287-00000095.wav` for `basic` +- `emotion_prompt.wav` for `emotion` +- `yue_prompt.wav` for `dialect` +- `00000309-00000300.wav` for `speech_bgm` and `speech_sound` +- `CTS-CN-F2F-2019-11-11-423-012-A.wav` and `CTS-CN-F2F-2019-11-11-423-012-B.wav` for `podcast` + +## Validation Matrix + +The repo-facing example is intended to cover the same dense TTS workflows used +by the local Ming validation script: + +| Case | Blocking `deploy/ming_tts.yaml` | Async chunk `deploy/ming_tts.yaml` | Extra inputs | +|---|---:|---:|---| +| `style` | Yes | Optional smoke test | none | +| `ip` | Yes | Optional smoke test | none | +| `bgm` | Yes | Optional smoke test | none | +| `tta` | Yes | Optional smoke test | none | +| `emotion` | Yes | Yes | `--ref-audio emotion_prompt.wav` | +| `basic` | Yes | Yes | `--ref-audio 10002287-00000095.wav` | +| `dialect` | Yes | Yes | `--ref-audio yue_prompt.wav` | +| `zero_shot` | Yes | Yes | `--ref-audio 10002287-00000094.wav --ref-text ...` | +| `podcast` | Yes | Yes | two `--ref-audio-paths` | +| `speech_bgm` | Yes | Yes | `--ref-audio 00000309-00000300.wav` | +| `speech_sound` | Yes | Yes | `--ref-audio 00000309-00000300.wav` | + +## Validated Outputs + +The following measurements are retained from an earlier L4 CUDA validation; +they are not ROCm benchmark results. Default async_chunk matched blocking +output frame counts and Stage-1 patch counts for every case: + +| Case | Blocking frames / patches / sec | Async chunk frames / patches / sec | +|---|---:|---:| +| `style` | 409248 / 29 / 9.28 | 409248 / 29 / 9.28 | +| `ip` | 183456 / 13 / 4.16 | 183456 / 13 / 4.16 | +| `bgm` | 1326528 / 94 / 30.08 | 1326528 / 94 / 30.08 | +| `tta` | 465696 / 33 / 10.56 | 465696 / 33 / 10.56 | +| `emotion` | 324576 / 23 / 7.36 | 324576 / 23 / 7.36 | +| `basic` | 211680 / 15 / 4.80 | 211680 / 15 / 4.80 | +| `dialect` | 239904 / 17 / 5.44 | 239904 / 17 / 5.44 | +| `zero_shot` | 409248 / 29 / 9.28 | 409248 / 29 / 9.28 | +| `podcast` | 437472 / 31 / 9.92 | 437472 / 31 / 9.92 | +| `speech_bgm` | 296352 / 21 / 6.72 | 296352 / 21 / 6.72 | +| `speech_sound` | 352800 / 25 / 8.00 | 352800 / 25 / 8.00 | + +## Key Arguments + +| Argument | Description | +|---|---| +| `--model` | Hugging Face repo or local Ming checkpoint path | +| `--deploy-config` | Deploy config YAML. Use `vllm_omni/deploy/ming_tts.yaml` | +| `--case` | Built-in demo case | +| `--ref-audio` | Single reference wav path for cloning-style cases | +| `--ref-audio-paths` | Multiple reference wav paths, used by `podcast` | +| `--ref-text` | Reference transcript. Required for `zero_shot` | +| `--instructions` | Free-form Ming instruction string | +| `--instruction-json` | Structured Ming instruction JSON | +| `--speaker-embedding` | JSON file containing a 192-d speaker embedding | +| `--extract-speaker-embeddings` | Force CampPlus speaker extraction from the provided reference audio paths | +| `--max-decode-steps` | Override `ming_max_decode_steps` | +| `--num-prompts` | Repeat the same case N times. Outputs are indexed when `N > 1` | +| `--streaming` | Use `AsyncOmni` and async_chunk transport | +| `--enforce-eager` | Recommended for Ming dense; non-eager is out of scope | +| `--enable-stats` / `--log-stats` | Enable vLLM-Omni per-request stats logging | +| `--stats-log-file` | Optional path for the stats log | +| `--metadata-json` | Optional path for the run manifest JSON | +| `--stage-init-timeout` | Per-stage initialization timeout in seconds | +| `--init-timeout` | Total initialization timeout in seconds | +| `--batch-timeout` | Batch timeout in seconds | +| `--worker-backend` | `multi_process` or `ray` | +| `--ray-address` | Ray cluster address when using `--worker-backend ray` | + +## Output + +- The script writes one mono 44.1 kHz WAV file per run +- Default output directory: `output_audio/` +- Default filename: `ming_.wav` +- When `--num-prompts > 1`, outputs are indexed as `ming__00000.wav`, `..._00001.wav`, etc. +- When stats are enabled, the script can also write: + - a stats log file such as `ming_style_pipeline.log` + - a manifest JSON with per-output metadata, stage durations, peak memory info, + and streaming client latency metrics when `--streaming` is used + +## Notes + +- `style` and `ip` are zero-speaker paths and do not require a reference clip +- `emotion`, `basic`, `dialect`, `speech_bgm`, and `speech_sound` require one reference clip +- `zero_shot` requires both `--ref-audio` and `--ref-text` +- `podcast` requires at least two reference clips via `--ref-audio-paths` +- `podcast` automatically extracts one speaker embedding per reference clip +- `--speaker-embedding` may contain either one 192-d vector or a list of 192-d vectors +- `--enforce-eager` was used for the validated runs +- The earlier L4 validation used SDPA for the Ming audio VAE instead of + FlashAttention2, which is the preferred default when available. diff --git a/examples/offline_inference/text_to_speech/ming_tts/cases.yaml b/examples/offline_inference/text_to_speech/ming_tts/cases.yaml new file mode 100644 index 000000000..d17b2761a --- /dev/null +++ b/examples/offline_inference/text_to_speech/ming_tts/cases.yaml @@ -0,0 +1,110 @@ +style: + prompt: "Please generate speech based on the following description.\n" + text: "我会一直在这里陪着你,直到你慢慢、慢慢地沉入那个最温柔的梦里……好吗?" + instruction: + 风格: >- + 这是一种ASMR耳语,属于一种旨在引发特殊感官体验的创意风格。这个女性使用轻柔的普通话进行耳语,声音气音成分重。音量极低,紧贴麦克风,语速极慢,旨在制造触发听者颅内快感的声学刺激。 + use_zero_spk_emb: true + max_decode_steps: 200 + +ip: + prompt: "Please generate speech based on the following description.\n" + text: "这款产品的名字,叫变态坑爹牛肉丸。" + instruction: + IP: "灵小甄" + use_zero_spk_emb: true + max_decode_steps: 200 + +bgm: + prompt: "Please generate music based on the following description.\n" + text: " Genre: 电子舞曲. Mood: 自信 / 坚定. Instrument: 架子鼓. Theme: 节日. Duration: 30s." + instruction: null + use_zero_spk_emb: false + max_decode_steps: 400 + +tta: + prompt: "Please generate audio events based on given text.\n" + text: "Thunder and a gentle rain" + instruction: null + use_zero_spk_emb: false + max_decode_steps: 200 + cfg: 4.5 + sigma: 0.3 + temperature: 2.5 + +emotion: + prompt: "Please generate speech based on the following description.\n" + text: "我竟然抢到了陈奕迅的演唱会门票!太棒了!终于可以现场听一听他的歌声了!" + instruction: + 情感: "高兴" + requires_ref_audio: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +basic: + prompt: "Please generate speech based on the following description.\n" + text: "简单地说,这相当于惠普把消费领域市场拱手相让了。" + instruction: + 语速: "快速" + 基频: "中" + 音量: "中" + requires_ref_audio: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +dialect: + prompt: "Please generate speech based on the following description.\n" + text: "我觉得社会企业同个人都有责任" + instruction: + 方言: "广粤话" + requires_ref_audio: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +zero_shot: + prompt: "Please generate speech based on the following description.\n" + text: "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" + instruction: null + requires_ref_audio: true + requires_ref_text: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +podcast: + prompt: "Please generate speech based on the following description.\n" + text: " speaker_1:你可以说一下,就大概说一下,可能虽然我也不知道,我看过那部电影没有。\n speaker_2:就是那个叫什么,变相一节课的嘛。\n speaker_1:嗯。\n speaker_2:一部搞笑的电影。\n speaker_1:一部搞笑的。\n" + instruction: null + prompt_text: " speaker_1:并且我们还要进行每个月还要考核 笔试的话还要进行笔试,做个,当服务员还要去笔试了\n speaker_2:对啊,这真的很奇怪,就是 单纯的因,单纯自己工资不高,只是因为可能人家那个店比较出名一点,就对你苛刻要求\n" + requires_ref_audio_count: 2 + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +speech_bgm: + prompt: "Please generate speech based on the following description.\n" + text: "此次业绩下滑原因,可归结为企业停止服务某些品牌,而带来的负面影响。" + instruction: + BGM: + Genre: "当代古典音乐." + Mood: "温暖 / 友善." + Instrument: "电吉他" + Theme: "节日." + SNR: 10.0 + ENV: null + requires_ref_audio: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 + +speech_sound: + prompt: "Please generate speech based on the following description.\n" + text: "此次业绩下滑原因,可归结为企业停止服务某些品牌,而带来的负面影响。" + instruction: + BGM: + ENV: "Birds chirping" + SNR: 10.0 + Genre: null + Mood: null + Instrument: null + Theme: null + requires_ref_audio: true + auto_extract_speaker_embeddings: true + max_decode_steps: 200 diff --git a/examples/offline_inference/text_to_speech/ming_tts/end2end.py b/examples/offline_inference/text_to_speech/ming_tts/end2end.py new file mode 100644 index 000000000..1a6991e40 --- /dev/null +++ b/examples/offline_inference/text_to_speech/ming_tts/end2end.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Offline inference demo for Ming-omni-tts via vLLM Omni.""" + +import json +import os +from pathlib import Path + +import soundfile as sf +import torch +import torchaudio +import yaml +from transformers import AutoTokenizer +from vllm.utils.argparse_utils import FlexibleArgumentParser + +from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( + KEY_CFG, + KEY_MAX_DECODE_STEPS, + KEY_SIGMA, + KEY_TEMPERATURE, + SAMPLE_RATE, +) +from vllm_omni.model_executor.models.ming_tts.prompt_assembly import build_ming_dense_prompt +from vllm_omni.model_executor.models.ming_tts.speaker_extractor import MingSpeakerEmbeddingExtractor + +try: + from .runner import ( + build_manifest, + build_sampling_params, + resolve_metadata_json, + resolve_stats_log_file, + run_generation, + ) +except ImportError: + from runner import ( + build_manifest, + build_sampling_params, + resolve_metadata_json, + resolve_stats_log_file, + run_generation, + ) + +_DEFAULT_MODEL = "inclusionAI/Ming-omni-tts-0.5B" +_REPO_ROOT = Path(__file__).resolve().parents[4] +_DEFAULT_DEPLOY_CONFIG = str(_REPO_ROOT / "vllm_omni/deploy/ming_tts.yaml") +_CASES_FILE = Path(__file__).with_name("cases.yaml") + +CASE_DEFAULTS = yaml.safe_load(_CASES_FILE.read_text(encoding="utf-8")) or {} +if not CASE_DEFAULTS: + raise RuntimeError(f"Empty or missing case definitions in {_CASES_FILE}") + + +def _build_parser() -> FlexibleArgumentParser: + p = FlexibleArgumentParser(description="Offline Ming-omni-tts example") + p.add_argument("--model", default=_DEFAULT_MODEL, help="Model name or local path") + p.add_argument("--deploy-config", default=None, help="Deploy config YAML; auto-selected when omitted") + p.add_argument("--case", choices=sorted(CASE_DEFAULTS), default="style", help="Built-in demo case") + p.add_argument("--text", default=None, help="Override case text") + p.add_argument("--prompt", default=None, help="Override the system prompt prefix") + p.add_argument("--instructions", default=None, help="Free-form Ming instruction string") + p.add_argument( + "--instruction-json", default=None, help='Structured Ming instruction JSON, e.g. \'{"方言":"广粤话"}\'' + ) + p.add_argument("--ref-audio", default=None, help="Single reference audio path for cloning-style cases") + p.add_argument("--ref-audio-paths", nargs="+", default=None, help="Multiple reference audio paths (podcast)") + p.add_argument("--ref-text", default=None, help="Reference transcript; required for zero_shot") + p.add_argument("--speaker-embedding", default=None, help="Path to a JSON speaker embedding file") + p.add_argument( + "--extract-speaker-embeddings", + action="store_true", + help="Extract speaker embeddings from ref audio via campplus.onnx", + ) + p.add_argument("--max-decode-steps", type=int, default=None, help="Override ming_max_decode_steps") + p.add_argument("--output-dir", default="output_audio", help="Directory for output wav files") + p.add_argument("--output-name", default=None, help="Output wav filename") + p.add_argument("--num-prompts", type=int, default=1, help="Repeat the same prompt N times") + p.add_argument("--streaming", action="store_true", help="Use AsyncOmni with async_chunk streaming") + p.add_argument("--trust-remote-code", action="store_true") + p.add_argument("--enforce-eager", action="store_true") + p.add_argument( + "--log-stats", "--enable-stats", dest="log_stats", action="store_true", help="Enable Omni stats logging" + ) + p.add_argument("--stats-log-file", default=None, help="Path for the Omni stats log file") + p.add_argument("--metadata-json", default=None, help="Path for the run manifest JSON") + p.add_argument("--stage-init-timeout", type=int, default=300, help="Per-stage init timeout (s)") + p.add_argument("--init-timeout", type=int, default=600, help="Total init timeout (s)") + p.add_argument("--batch-timeout", type=int, default=5, help="Batch timeout (s)") + p.add_argument("--shm-threshold-bytes", type=int, default=65536) + p.add_argument("--worker-backend", default="multi_process", choices=["multi_process", "ray"]) + p.add_argument("--ray-address", default=None, help="Ray cluster address (--worker-backend ray)") + return p + + +def _finalize_args(args) -> None: + if args.instructions is not None and args.instruction_json is not None: + raise RuntimeError("Use either --instructions or --instruction-json, not both") + if args.num_prompts < 1: + raise RuntimeError("--num-prompts must be at least 1") + if args.streaming and args.num_prompts != 1: + raise RuntimeError("--streaming currently supports exactly one prompt") + if args.deploy_config is None: + args.deploy_config = _DEFAULT_DEPLOY_CONFIG + + +def _load_waveform(path: str) -> torch.Tensor: + samples, sr = sf.read(path, dtype="float32") + wav = torch.as_tensor(samples, dtype=torch.float32) + if wav.ndim == 2: + wav = wav.mean(dim=1) + wav = wav.reshape(1, -1) + if int(sr) != SAMPLE_RATE: + wav = torchaudio.functional.resample(wav, int(sr), SAMPLE_RATE) + return wav + + +def _load_speaker_embedding(path: str) -> torch.Tensor: + return torch.as_tensor(json.loads(Path(path).read_text(encoding="utf-8")), dtype=torch.float32) + + +def _ref_audio_paths(args) -> list[str]: + if args.ref_audio is not None and args.ref_audio_paths is not None: + raise RuntimeError("Use either --ref-audio or --ref-audio-paths, not both") + if args.ref_audio_paths is not None: + return list(args.ref_audio_paths) + return [args.ref_audio] if args.ref_audio else [] + + +def _resolve_reference_inputs(args, case: dict, paths: list[str]): + required = int(case.get("requires_ref_audio_count", 0)) + if required > 0 and len(paths) < required: + raise RuntimeError(f"Case '{args.case}' needs {required} ref audio paths via --ref-audio-paths") + if required <= 0 and case.get("requires_ref_audio") and not paths: + raise RuntimeError(f"--ref-audio required for case '{args.case}'") + if not paths: + return None + wavs = [_load_waveform(p) for p in paths] + return wavs[0] if len(wavs) == 1 else wavs + + +def _resolve_speaker_embedding(args, case: dict, paths: list[str]): + if args.speaker_embedding: + return _load_speaker_embedding(args.speaker_embedding) + if not (case.get("auto_extract_speaker_embeddings") or args.extract_speaker_embeddings) or not paths: + return None + embs = MingSpeakerEmbeddingExtractor(args.model).extract_many(paths) + if not embs: + raise RuntimeError("Speaker extraction produced no embeddings") + return embs[0] if len(embs) == 1 else torch.stack(embs, dim=0) + + +def _build_prompt_payload(tokenizer, args): + case = CASE_DEFAULTS[args.case] + paths = _ref_audio_paths(args) + prompt_text = args.ref_text if args.ref_text is not None else case.get("prompt_text") + if case.get("requires_ref_text") and not prompt_text: + raise RuntimeError(f"--ref-text required for case '{args.case}'") + prompt_waveform = _resolve_reference_inputs(args, case, paths) if prompt_text is not None else None + speaker_embedding = _resolve_speaker_embedding(args, case, paths) + use_zero_spk_emb = bool(case.get("use_zero_spk_emb")) and prompt_waveform is None and speaker_embedding is None + runtime_controls = {KEY_MAX_DECODE_STEPS: args.max_decode_steps or case["max_decode_steps"]} + for key, field in [(KEY_CFG, "cfg"), (KEY_SIGMA, "sigma"), (KEY_TEMPERATURE, "temperature")]: + if field in case: + runtime_controls[key] = case[field] + instruction = ( + json.loads(args.instruction_json) if args.instruction_json else (args.instructions or case.get("instruction")) + ) + return build_ming_dense_prompt( + tokenizer, + prompt=args.prompt or case["prompt"], + text=args.text or case["text"], + runtime_controls=runtime_controls, + instruction=instruction, + prompt_text=prompt_text, + prompt_waveform=prompt_waveform, + speaker_embedding=speaker_embedding, + use_zero_spk_emb=use_zero_spk_emb, + ) + + +def main(): + args = _build_parser().parse_args() + _finalize_args(args) + tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=False) + prompt_payload = _build_prompt_payload(tokenizer, args) + case = CASE_DEFAULTS[args.case] + sampling_params_list = build_sampling_params(args.max_decode_steps or case["max_decode_steps"]) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + stats_log_file = resolve_stats_log_file(args) + summaries = run_generation(args, prompt_payload, sampling_params_list, output_dir, stats_log_file) + metadata_json = resolve_metadata_json(args) + manifest = build_manifest(args, prompt_payload, stats_log_file, summaries) + if metadata_json is not None: + Path(metadata_json).write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"Saved run manifest to {metadata_json}") + + +if __name__ == "__main__": + os.environ.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + main() diff --git a/examples/offline_inference/text_to_speech/ming_tts/runner.py b/examples/offline_inference/text_to_speech/ming_tts/runner.py new file mode 100644 index 000000000..35852ce04 --- /dev/null +++ b/examples/offline_inference/text_to_speech/ming_tts/runner.py @@ -0,0 +1,277 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import asyncio +import json +import time +import uuid +import wave +from pathlib import Path + +import torch +from vllm import SamplingParams + +from vllm_omni import AsyncOmni, Omni +from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( + KEY_SPEAKER_EMBEDDING, + SAMPLE_RATE, + TEXT_EOS_TOKEN_ID, +) + + +def coerce_audio_tensor(audio, *, async_chunk: bool) -> torch.Tensor: + if isinstance(audio, list): + parts = [] + for item in audio: + tensor = torch.as_tensor(item, dtype=torch.float32).reshape(-1) + if tensor.numel() > 0: + parts.append(tensor) + if not parts: + return torch.zeros((0,), dtype=torch.float32) + return torch.cat(parts, dim=0) + + return torch.as_tensor(audio, dtype=torch.float32).reshape(-1) + + +def resolve_sr(sr) -> int: + if isinstance(sr, list): + sr = sr[-1] + if hasattr(sr, "item"): + return int(sr.item()) + return int(sr) + + +def extract_sample_rate(multimodal_output: dict) -> int: + sr = multimodal_output.get("sr") + if sr is None: + raise RuntimeError("Expected multimodal_output['sr']") + return resolve_sr(sr) + + +def write_wav(path: str, audio: torch.Tensor, sample_rate: int) -> None: + audio = audio.clamp(-1.0, 1.0) + pcm16 = (audio * 32767.0).round().to(torch.int16).cpu().numpy() + with wave.open(path, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) + wav_file.setframerate(int(sample_rate)) + wav_file.writeframes(pcm16.tobytes()) + + +def request_index(request_id: str | None, fallback: int) -> int: + try: + return int(request_id) + except (TypeError, ValueError): + if isinstance(request_id, str): + head = request_id.split("_", 1)[0] + if head.isdigit(): + return int(head) + return fallback + + +def audio_summary(audio: torch.Tensor, sample_rate: int) -> dict: + waveform = audio.detach().cpu().reshape(-1).to(torch.float32) + return { + "sample_rate": int(sample_rate), + "num_samples": int(waveform.numel()), + "duration_seconds": float(waveform.numel()) / float(sample_rate), + "max_abs_amplitude": float(waveform.abs().max().item()) if waveform.numel() > 0 else 0.0, + } + + +def resolve_output_name(output_name: str | None, case: str, index: int, total: int) -> str: + if total == 1: + return output_name or f"ming_{case}.wav" + base = Path(output_name or f"ming_{case}.wav") + return f"{base.stem}_{index:05d}{base.suffix or '.wav'}" + + +def resolve_stats_log_file(args) -> str | None: + if not args.log_stats: + return None + if args.stats_log_file: + return args.stats_log_file + base = Path(args.output_name or f"ming_{args.case}.wav").stem + return str(Path(args.output_dir) / f"{base}_pipeline.log") + + +def resolve_metadata_json(args) -> str | None: + if args.metadata_json: + return args.metadata_json + if args.log_stats: + base = Path(args.output_name or f"ming_{args.case}.wav").stem + return str(Path(args.output_dir) / f"{base}_manifest.json") + return None + + +def build_manifest(args, prompt_payload, stats_log_file: str | None, outputs: list[dict]) -> dict: + additional_information = {} + if isinstance(prompt_payload, dict): + additional_information = dict(prompt_payload.get("additional_information", {})) + return { + "model": args.model, + "case": args.case, + "streaming": bool(args.streaming), + "deploy_config": args.deploy_config, + "enforce_eager": bool(args.enforce_eager), + "num_prompts": int(args.num_prompts), + "log_stats": bool(args.log_stats), + "stats_log_file": stats_log_file, + "prompt_text": additional_information.get("prompt_text"), + "instruction": additional_information.get("instruction"), + "speaker_embedding_shape": ( + list(additional_information[KEY_SPEAKER_EMBEDDING].shape) + if KEY_SPEAKER_EMBEDDING in additional_information + and hasattr(additional_information[KEY_SPEAKER_EMBEDDING], "shape") + else None + ), + "outputs": outputs, + "generated_at_unix": time.time(), + } + + +def build_engine_kwargs(args, stats_log_file: str | None) -> dict: + kwargs = { + "model": args.model, + "deploy_config": args.deploy_config, + "enforce_eager": args.enforce_eager, + "trust_remote_code": args.trust_remote_code, + "log_stats": args.log_stats, + "stage_init_timeout": args.stage_init_timeout, + "init_timeout": args.init_timeout, + "batch_timeout": args.batch_timeout, + "shm_threshold_bytes": args.shm_threshold_bytes, + "worker_backend": args.worker_backend, + } + if stats_log_file is not None: + kwargs["log_file"] = stats_log_file + if args.ray_address is not None: + kwargs["ray_address"] = args.ray_address + return kwargs + + +def build_sampling_params(max_decode_steps: int) -> list[SamplingParams]: + return [ + SamplingParams( + temperature=0.0, + max_tokens=max_decode_steps + 1, + stop_token_ids=[int(TEXT_EOS_TOKEN_ID)], + ), + SamplingParams(temperature=0.0, max_tokens=1), + ] + + +async def run_streaming(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None): + engine = AsyncOmni(**build_engine_kwargs(args, stats_log_file)) + try: + all_audio_chunks = [] + accumulated_samples = 0 + chunk_idx = 0 + start_time = time.time() + chunk_times = [] + ttfp_seconds = None + final_stage_output = None + async for stage_output in engine.generate( + prompt=prompt_payload, + request_id=str(uuid.uuid4()), + sampling_params_list=sampling_params_list, + ): + final_stage_output = stage_output + multimodal_output = stage_output.multimodal_output or {} + audio = multimodal_output.get("audio") + if audio is None: + continue + + finished = stage_output.finished + if isinstance(audio, torch.Tensor): + if finished: + audio_chunk = audio[accumulated_samples:].float().detach().cpu() + else: + audio_chunk = audio.float().detach().cpu() + elif isinstance(audio, list): + audio_chunk = torch.as_tensor(audio[chunk_idx], dtype=torch.float32).reshape(-1).cpu() + else: + audio_chunk = torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu() + + accumulated_samples += int(audio_chunk.numel()) + chunk_idx += 1 + if audio_chunk.numel() > 0: + now = time.time() + if ttfp_seconds is None: + ttfp_seconds = now - start_time + chunk_times.append(now) + all_audio_chunks.append(audio_chunk) + + if not all_audio_chunks: + raise RuntimeError("Streaming Ming example produced no audio chunks") + + waveform = torch.cat(all_audio_chunks, dim=0) + output_name = resolve_output_name(args.output_name, args.case, 0, 1) + output_path = str(output_dir / output_name) + write_wav(output_path, waveform, SAMPLE_RATE) + summary = { + "request_id": getattr(final_stage_output, "request_id", None), + "stage_id": getattr(final_stage_output, "stage_id", None), + "output_path": output_path, + "stage_durations": getattr(final_stage_output, "stage_durations", {}), + "peak_memory_mb": getattr(final_stage_output, "peak_memory_mb", 0.0), + "ttfp_seconds": ttfp_seconds, + "mean_inter_chunk_seconds": ( + sum(t1 - t0 for t0, t1 in zip(chunk_times, chunk_times[1:])) / (len(chunk_times) - 1) + if len(chunk_times) > 1 + else None + ), + } + summary.update(audio_summary(waveform, SAMPLE_RATE)) + print(f"Saved streaming output to {output_path}") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return [summary] + finally: + engine.shutdown() + + +def run_non_streaming(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None): + engine = Omni(**build_engine_kwargs(args, stats_log_file)) + try: + outputs = engine.generate( + prompts=[prompt_payload for _ in range(args.num_prompts)], + sampling_params_list=sampling_params_list, + py_generator=False, + ) + summaries = [] + for fallback_index, output in enumerate(outputs): + if output.final_output_type != "audio": + continue + multimodal_output = output.multimodal_output or {} + waveform = coerce_audio_tensor(multimodal_output.get("audio"), async_chunk=False) + sample_rate = extract_sample_rate(multimodal_output) + output_name = resolve_output_name( + args.output_name, + args.case, + request_index(output.request_id, fallback_index), + args.num_prompts, + ) + output_path = str(output_dir / output_name) + write_wav(output_path, waveform, sample_rate) + summary = { + "request_id": output.request_id, + "stage_id": output.stage_id, + "output_path": output_path, + "stage_durations": output.stage_durations, + "peak_memory_mb": output.peak_memory_mb, + } + summary.update(audio_summary(waveform, sample_rate)) + summaries.append(summary) + print(f"Saved output to {output_path}") + print(json.dumps(summary, ensure_ascii=False, indent=2)) + if not summaries: + raise RuntimeError("Non-streaming Ming example produced no audio outputs") + return summaries + finally: + engine.close() + + +def run_generation(args, prompt_payload, sampling_params_list, output_dir: Path, stats_log_file: str | None): + if args.streaming: + return asyncio.run(run_streaming(args, prompt_payload, sampling_params_list, output_dir, stats_log_file)) + return run_non_streaming(args, prompt_payload, sampling_params_list, output_dir, stats_log_file) diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index a3b27b722..c9498596e 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -16,6 +16,7 @@ For the full list of supported architectures across all modalities, see |---|---|---|---|---|---| | 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) | — | ✓ | +| Ming-omni-tts | `inclusionAI/Ming-omni-tts-0.5B` | ✓ (`ref_audio` / `speaker_embedding`) | ✓ (PCM stream) | IP labels + structured `instructions` | — | | Ming-flash-omni-TTS | `Jonathan1909/Ming-flash-omni-2.0` | — (caption-controlled) | — | caption fields (`instructions`) | — | | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✓ (`ref_audio` required) | ✓ (PCM stream) | — | ✓ | | OmniVoice | `k2-fsa/OmniVoice` | ✓ | — | — | — | @@ -202,6 +203,51 @@ python fish_speech/gradio_demo.py --api-base http://localhost:8091 # if server --- +## Ming-omni-tts + +Dense 0.5B two-stage TTS served through `/v1/audio/speech`. Ming uses the standard speech endpoint plus structured controls in `instructions`, `voice`, `language`, `ref_audio`, `ref_text`, and `speaker_embedding`. + +### Launch +```bash +bash examples/online_serving/text_to_speech/ming_tts/run_server.sh +``` +Equivalent manual command: +```bash +vllm-omni serve inclusionAI/Ming-omni-tts-0.5B \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --host 0.0.0.0 --port 8091 \ + --enforce-eager --omni +``` + +### Sending requests +```bash +python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \ + --text "你好,这是 Ming 在线语音合成测试。" +``` + +Structured dialect control: +```bash +python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \ + --text "我觉得社会企业同个人都有责任" \ + --instruction-json '{"方言":"广粤话"}' \ + --ref-audio /path/to/yue_prompt.wav +``` + +Zero-shot cloning: +```bash +python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \ + --text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \ + --ref-audio /path/to/10002287-00000094.wav \ + --ref-text "在此奉劝大家别乱打美白针。" +``` + +### Notes +- `run_curl.sh` keeps a small sanity subset; use the Ming README for the broader request cookbook. +- Online serving is speech-shaped today; music-only `bgm` and text-to-audio `tta` remain offline examples. +- Full request details live in [`ming_tts/README.md`](ming_tts/README.md). + +--- + ## Ming-flash-omni-TTS Standalone talker-only deployment of Ming-flash-omni-2.0. Voice is controlled through caption text passed via `instructions`. diff --git a/examples/online_serving/text_to_speech/ming_tts/README.md b/examples/online_serving/text_to_speech/ming_tts/README.md new file mode 100644 index 000000000..d0e0b0194 --- /dev/null +++ b/examples/online_serving/text_to_speech/ming_tts/README.md @@ -0,0 +1,113 @@ +# Ming-omni-tts Online Serving + +Serve the dense `inclusionAI/Ming-omni-tts-0.5B` two-stage TTS model through +the OpenAI-compatible `/v1/audio/speech` endpoint. + +## Start Server + +```bash +vllm-omni serve inclusionAI/Ming-omni-tts-0.5B \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --omni \ + --port 8091 \ + --enforce-eager +``` + +Or: + +```bash +cd examples/online_serving/text_to_speech/ming_tts +./run_server.sh +``` + +The tested ROCm environment is summarized in the +[Ming recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/inclusionAI/Ming-omni-tts-0.5B.md). + +## Send Requests + +The Python client targets `http://localhost:8091/v1` with `api_key=EMPTY`; it +does not call OpenAI's hosted API. + +```bash +python openai_speech_client.py \ + --text "你好,这是 Ming 在线语音合成测试。" \ + --max-new-tokens 200 +``` + +Style or dialect controls can be plain text or Ming JSON. The upstream +dialect example also uses `yue_prompt.wav` for speaker conditioning: + +```bash +python openai_speech_client.py \ + --text "我觉得社会企业同个人都有责任" \ + --instruction-json '{"方言":"广粤话"}' \ + --ref-audio /path/to/yue_prompt.wav \ + --max-new-tokens 200 +``` + +When `--ref-audio` is supplied without `--ref-text`, the server extracts the +Ming speaker embedding, matching upstream `use_spk_emb=True`, without using the +audio as a zero-shot prompt. + +Reference-audio cloning: + +```bash +python openai_speech_client.py \ + --text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \ + --ref-audio /path/to/10002287-00000094.wav \ + --ref-text "在此奉劝大家别乱打美白针。" \ + --max-new-tokens 200 +``` + +Podcast-style multi-speaker prompt: + +```bash +python openai_speech_client.py \ + --text " speaker_1:你可以说一下,就大概说一下,可能虽然我也不知道,我看过那部电影没有。 + speaker_2:就是那个叫什么,变相一节课的嘛。 + speaker_1:嗯。 + speaker_2:一部搞笑的电影。 + speaker_1:一部搞笑的。" \ + --ref-audio /path/to/CTS-CN-F2F-2019-11-11-423-012-A.wav \ + --ref-audio /path/to/CTS-CN-F2F-2019-11-11-423-012-B.wav \ + --ref-text " speaker_1:并且我们还要进行每个月还要考核 笔试的话还要进行笔试,做个,当服务员还要去笔试了 + speaker_2:对啊,这真的很奇怪,就是 单纯的因,单纯自己工资不高,只是因为可能人家那个店比较出名一点,就对你苛刻要求" +``` + +Streaming PCM: + +```bash +python openai_speech_client.py \ + --text "你好,这是流式输出测试。" \ + --stream \ + --output ming_output.pcm +``` + +`run_curl.sh` keeps small smoke checks: + +```bash +./run_curl.sh basic +REF_AUDIO=/path/to/reference.wav REF_TEXT="在此奉劝大家别乱打美白针。" ./run_curl.sh zero_shot +./run_curl.sh stream +``` + +## Request Fields + +| Field | Ming meaning | +|-------|--------------| +| `input` | target text | +| `instructions` | plain style text, or JSON object for structured Ming controls | +| `voice` | Ming IP voice label unless it resolves to an uploaded speaker | +| `language` | Ming `方言` control | +| `ref_audio` | speaker reference; with `ref_text`, also supplies the prompt waveform | +| `ref_text` | transcript enabling zero-shot or podcast prompt-latent conditioning | +| `speaker_embedding` | 192-d Ming speaker embedding | +| `max_new_tokens` | Ming `max_decode_steps` | + +## Notes + +- `ref_audio` accepts local paths through the client, remote URLs, `file://`, + or `data:` URLs. +- Non-streaming responses return WAV bytes; streaming responses return PCM. +- Music-only `bgm` generation is offline-only until the API exposes Ming + prompt-mode selection. diff --git a/examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py b/examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py new file mode 100644 index 000000000..536bfb048 --- /dev/null +++ b/examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py @@ -0,0 +1,225 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""OpenAI-compatible client for Ming-omni-tts via /v1/audio/speech. + +Examples: + python openai_speech_client.py --text "你好,世界" + python openai_speech_client.py --text "我觉得社会企业同个人都有责任" \ + --instruction-json '{"方言":"广粤话"}' --ref-audio yue_prompt.wav --max-new-tokens 200 + python openai_speech_client.py \ + --text "我们的愿景是构建未来服务业的数字化基础设施,为世界带来更多微小而美好的改变。" \ + --ref-audio 10002287-00000094.wav --ref-text "在此奉劝大家别乱打美白针。" \ + --max-new-tokens 200 + python openai_speech_client.py --text "speaker_1:你好。 speaker_2:你好。" \ + --ref-audio speaker_1.wav --ref-audio speaker_2.wav --ref-text "speaker_1:你好。 speaker_2:你好。" + python openai_speech_client.py --text "你好,这是流式输出测试。" \ + --stream --output ming_output.pcm +""" + +import argparse +import base64 +import json +import os + +import httpx + +DEFAULT_API_BASE = "http://localhost:8091" +DEFAULT_API_KEY = "EMPTY" +DEFAULT_MODEL = "inclusionAI/Ming-omni-tts-0.5B" +EXPECTED_SPEAKER_EMBEDDING_DIM = 192 + + +def encode_audio_to_base64(audio_path: str) -> str: + """Encode a local audio file to 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_map = { + "wav": "audio/wav", + "mp3": "audio/mpeg", + "flac": "audio/flac", + "ogg": "audio/ogg", + "aac": "audio/aac", + } + mime_type = mime_map.get(ext, "audio/wav") + with open(audio_path, "rb") as f: + audio_b64 = base64.b64encode(f.read()).decode("utf-8") + return f"data:{mime_type};base64,{audio_b64}" + + +def load_speaker_embedding(path: str) -> list[float]: + """Load and validate a 192-d Ming speaker embedding JSON file.""" + with open(path, encoding="utf-8") as f: + data = json.load(f) + + if not isinstance(data, list): + raise ValueError("speaker_embedding file must contain a JSON list") + if len(data) != EXPECTED_SPEAKER_EMBEDDING_DIM: + raise ValueError( + f"Ming dense speaker_embedding must have {EXPECTED_SPEAKER_EMBEDDING_DIM} values, got {len(data)}" + ) + + values = [] + for index, value in enumerate(data): + try: + values.append(float(value)) + except (TypeError, ValueError) as exc: + raise ValueError(f"speaker_embedding[{index}] must be a number, got {value!r}") from exc + return values + + +def build_instruction_payload(args) -> str | None: + """Return a string payload for the API `instructions` field.""" + if args.instructions and args.instruction_json: + raise ValueError("Use either --instructions or --instruction-json, not both") + if args.instruction_json: + parsed = json.loads(args.instruction_json) + return json.dumps(parsed, ensure_ascii=False) + return args.instructions + + +def validate_args(args) -> None: + """Fail fast on invalid combinations before hitting the server.""" + if args.ref_text and not args.ref_audio: + raise ValueError("--ref-audio is required when --ref-text is provided") + if args.speaker_embedding and args.ref_audio and len(args.ref_audio) > 1: + raise ValueError("--speaker-embedding cannot be combined with multiple --ref-audio values") + + +def run_tts(args) -> None: + """Generate speech via the OpenAI-compatible /v1/audio/speech API.""" + validate_args(args) + + payload = { + "model": args.model, + "input": args.text, + "response_format": args.response_format, + } + + if args.voice: + payload["voice"] = args.voice + if args.task_type: + payload["task_type"] = args.task_type + if args.dialect: + payload["language"] = args.dialect + + instructions = build_instruction_payload(args) + if instructions: + payload["instructions"] = instructions + + if args.ref_audio: + ref_audio = [] + for audio in args.ref_audio: + if audio.startswith(("http://", "https://", "data:")): + ref_audio.append(audio) + else: + ref_audio.append(encode_audio_to_base64(audio)) + payload["ref_audio"] = ref_audio[0] if len(ref_audio) == 1 else ref_audio + if args.ref_text: + payload["ref_text"] = args.ref_text + if args.speaker_embedding: + payload["speaker_embedding"] = load_speaker_embedding(args.speaker_embedding) + if args.max_new_tokens: + payload["max_new_tokens"] = args.max_new_tokens + if args.stream: + payload["stream"] = True + payload["response_format"] = "pcm" + + 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}") + print(f"Payload keys: {sorted(payload)}") + + if args.stream: + output_path = args.output or "ming_output.pcm" + with httpx.Client(timeout=300.0) as client: + with client.stream("POST", api_url, json=payload, headers=headers) as response: + if response.status_code != 200: + print(f"Error: {response.status_code}") + print(response.read().decode()) + return + with open(output_path, "wb") as f: + for chunk in response.iter_bytes(): + f.write(chunk) + print(f"Streamed PCM audio to: {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}") + print(response.text) + return + + try: + text = response.content.decode("utf-8") + if text.startswith('{"error"'): + print(f"Error: {text}") + return + except UnicodeDecodeError: + pass + + output_path = args.output or "ming_output.wav" + with open(output_path, "wb") as f: + f.write(response.content) + print(f"Audio saved to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="OpenAI-compatible client for Ming-omni-tts via /v1/audio/speech") + 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 or path") + parser.add_argument("--text", required=True, help="Text to synthesize") + parser.add_argument( + "--task-type", + default=None, + choices=["CustomVoice", "VoiceDesign", "Base"], + help="Optional compatibility task type. Ming accepts the same field but primarily uses prompt metadata.", + ) + parser.add_argument( + "--voice", + default=None, + help="Maps to Ming `IP` when using built-in character voices, or to an uploaded voice sample name", + ) + parser.add_argument("--dialect", default=None, help="Maps to Ming `方言`") + parser.add_argument("--instructions", default=None, help="Free-form Ming instruction string") + parser.add_argument( + "--instruction-json", + default=None, + help='Structured Ming instruction JSON, for example \'{"情感":"高兴"}\'', + ) + parser.add_argument( + "--ref-audio", + action="append", + default=None, + help="Reference audio path, URL, or data URL. Repeat for podcast-style multi-speaker prompts.", + ) + parser.add_argument("--ref-text", default=None, help="Reference transcript for cloning") + parser.add_argument( + "--speaker-embedding", default=None, help="Path to a JSON file containing a 192-d speaker embedding" + ) + parser.add_argument("--max-new-tokens", type=int, default=None, help="Override ming_max_decode_steps") + parser.add_argument("--stream", action="store_true", help="Enable streaming PCM output") + parser.add_argument( + "--response-format", + default="wav", + choices=["wav", "mp3", "flac", "pcm", "aac", "opus"], + help="Audio format when not streaming", + ) + parser.add_argument("--output", "-o", default=None, help="Output file path") + args = parser.parse_args() + try: + run_tts(args) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"Error: {exc}") from exc + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/text_to_speech/ming_tts/run_curl.sh b/examples/online_serving/text_to_speech/ming_tts/run_curl.sh new file mode 100755 index 000000000..10fcaf59a --- /dev/null +++ b/examples/online_serving/text_to_speech/ming_tts/run_curl.sh @@ -0,0 +1,76 @@ +#!/bin/bash +set -euo pipefail + +MODE="${1:-basic}" +HOST="${HOST:-localhost}" +PORT="${PORT:-8091}" +MODEL="${MODEL:-inclusionAI/Ming-omni-tts-0.5B}" +API_URL="http://${HOST}:${PORT}/v1/audio/speech" +TEXT="${TEXT:-你好,这是 Ming 在线语音合成测试。}" +OUTPUT="${OUTPUT:-ming_output.wav}" +STREAM_OUTPUT="${STREAM_OUTPUT:-ming_output.pcm}" +REF_AUDIO="${REF_AUDIO:-}" +REF_TEXT="${REF_TEXT:-}" + +post_json() { + local payload="$1" + local output_path="$2" + curl -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer EMPTY" \ + -d "$payload" \ + --output "$output_path" +} + +case "$MODE" in + basic) + post_json "{ + \"model\": \"${MODEL}\", + \"input\": \"${TEXT}\", + \"response_format\": \"wav\" + }" "$OUTPUT" + ;; + zero_shot) + if [ -z "$REF_AUDIO" ] || [ -z "$REF_TEXT" ]; then + echo "zero_shot requires REF_AUDIO and REF_TEXT" >&2 + exit 1 + fi + python - <<'PY' > /tmp/ming_zero_shot_payload.json +import base64 +import json +import mimetypes +import os +from pathlib import Path + +path = Path(os.environ["REF_AUDIO"]) +mime_type = mimetypes.guess_type(path.name)[0] or "audio/wav" +payload = { + "model": os.environ["MODEL"], + "input": os.environ["TEXT"], + "ref_audio": f"data:{mime_type};base64,{base64.b64encode(path.read_bytes()).decode('utf-8')}", + "ref_text": os.environ["REF_TEXT"], + "response_format": "wav", +} +print(json.dumps(payload, ensure_ascii=False)) +PY + curl -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer EMPTY" \ + --data-binary @/tmp/ming_zero_shot_payload.json \ + --output "$OUTPUT" + rm -f /tmp/ming_zero_shot_payload.json + ;; + stream) + post_json "{ + \"model\": \"${MODEL}\", + \"input\": \"${TEXT}\", + \"stream\": true, + \"response_format\": \"pcm\" + }" "$STREAM_OUTPUT" + ;; + *) + echo "Unknown mode: $MODE" >&2 + echo "Supported sanity checks: basic, zero_shot, stream" >&2 + exit 1 + ;; +esac diff --git a/examples/online_serving/text_to_speech/ming_tts/run_server.sh b/examples/online_serving/text_to_speech/ming_tts/run_server.sh new file mode 100755 index 000000000..7d294d950 --- /dev/null +++ b/examples/online_serving/text_to_speech/ming_tts/run_server.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Launch vLLM-Omni server for Ming-omni-tts. +# +# Usage: +# ./run_server.sh +# PORT=8000 ./run_server.sh + +set -e + +DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$DIR/../../../.." && pwd)" + +MODEL="${MODEL:-inclusionAI/Ming-omni-tts-0.5B}" +PORT="${PORT:-8091}" +DEPLOY_CONFIG="${DEPLOY_CONFIG:-$ROOT/vllm_omni/deploy/ming_tts.yaml}" + +echo "Starting Ming-omni-tts server with model: $MODEL" +echo "Deploy config: $DEPLOY_CONFIG" + +vllm-omni serve "$MODEL" \ + --deploy-config "$DEPLOY_CONFIG" \ + --host 0.0.0.0 \ + --port "$PORT" \ + --enforce-eager \ + --omni diff --git a/recipes/README.md b/recipes/README.md index a0833827a..2bbc518df 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -33,6 +33,7 @@ recipes/ | [`fishaudio/Fish-Speech-S2-Pro.md`](./fishaudio/Fish-Speech-S2-Pro.md) | Online serving for TTS | 1x A800 80GB | | [`Helios/Helios.md`](./Helios/Helios.md) | Text-to-video, image-to-video, and video-to-video generation | 1x NVIDIA H20 | | [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB | +| [`inclusionAI/Ming-omni-tts-0.5B.md`](./inclusionAI/Ming-omni-tts-0.5B.md) | Offline + online dense Ming TTS/audio generation | 1x AMD `gfx942` (ROCm 7.2) | | [`LTX/LTX-2.md`](./LTX/LTX-2.md) | Text-to-video and image-to-video serving | 1x H200 141GB | | [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) | | [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB | diff --git a/recipes/inclusionAI/Ming-omni-tts-0.5B.md b/recipes/inclusionAI/Ming-omni-tts-0.5B.md new file mode 100644 index 000000000..cd1728034 --- /dev/null +++ b/recipes/inclusionAI/Ming-omni-tts-0.5B.md @@ -0,0 +1,82 @@ +# Ming-omni-tts 0.5B + +> Offline and online dense Ming text-to-speech on ROCm + +## Summary + +- Vendor: inclusionAI +- Model: `inclusionAI/Ming-omni-tts-0.5B` +- Task: Text-to-speech with style, dialect, cloning, and multi-speaker controls +- Mode: Online serving via the OpenAI-compatible `/v1/audio/speech` API; offline inference +- Maintainer: Community + +## References + +- [Model card](https://huggingface.co/inclusionAI/Ming-omni-tts-0.5B) +- [Upstream repository](https://github.com/inclusionAI/Ming-omni-tts) +- [Offline example](../../examples/offline_inference/text_to_speech/ming_tts/) +- [Online example](../../examples/online_serving/text_to_speech/ming_tts/) + +## Hardware Support + +## ROCm + +### 1x AMD `gfx942` + +#### Environment + +- OS: Ubuntu 22.04.5 LTS, x86_64 +- Python: 3.12.13 +- ROCm / HIP: 7.2.53211 +- vLLM version: 0.22.0 +- vLLM-Omni version or commit: 0.1.dev1873 / `99c35c410` +- Docker image: `vllm/vllm-omni-rocm:v0.22.0` + +#### Command + +From the vLLM-Omni repository root: + +```bash +docker run --rm \ + --group-add=video \ + --ipc=host \ + --cap-add=SYS_PTRACE \ + --security-opt seccomp=unconfined \ + --device /dev/kfd \ + --device /dev/dri \ + -v ~/.cache/huggingface:/root/.cache/huggingface \ + -v "$PWD":/app/vllm-omni \ + -w /app/vllm-omni \ + -e VLLM_ROCM_USE_AITER=0 \ + -p 8091:8091 \ + vllm/vllm-omni-rocm:v0.22.0 \ + --model inclusionAI/Ming-omni-tts-0.5B \ + --deploy-config vllm_omni/deploy/ming_tts.yaml \ + --omni \ + --port 8091 \ + --enforce-eager +``` + +#### Verification + +```bash +python examples/online_serving/text_to_speech/ming_tts/openai_speech_client.py \ + --text "我觉得社会企业同个人都有责任" \ + --instruction-json '{"方言":"广粤话"}' \ + --ref-audio /path/to/yue_prompt.wav \ + --max-new-tokens 200 \ + --output dialect.wav +``` + +`--ref-audio` matches upstream `use_spk_emb=True`; do not add `--ref-text` +for the dialect case. + +## Notes + +- The official ROCm image includes the platform dependencies. +- See the [ROCm installation guide](../../docs/getting_started/installation/gpu.md) + for interactive and source-build workflows. +- The [offline example](../../examples/offline_inference/text_to_speech/ming_tts/) + can be run from an interactive container. +- The tested environment uses `--enforce-eager`. +- Output is mono 44.1 kHz audio. diff --git a/tests/e2e/offline_inference/test_ming_flash_omni_expansion.py b/tests/e2e/offline_inference/test_ming_flash_omni_expansion.py index a81cfe6d4..19cf6fa4c 100644 --- a/tests/e2e/offline_inference/test_ming_flash_omni_expansion.py +++ b/tests/e2e/offline_inference/test_ming_flash_omni_expansion.py @@ -51,7 +51,7 @@ test_params = [(m, c) for m in models for c in [get_eager_config()]] @pytest.mark.core_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True) def test_text_to_text(omni_runner, omni_runner_handler) -> None: """ @@ -67,7 +67,7 @@ def test_text_to_text(omni_runner, omni_runner_handler) -> None: @pytest.mark.core_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True) def test_image_to_text(omni_runner, omni_runner_handler) -> None: """ @@ -84,7 +84,7 @@ def test_image_to_text(omni_runner, omni_runner_handler) -> None: @pytest.mark.core_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True) def test_audio_to_text(omni_runner, omni_runner_handler) -> None: """ @@ -103,7 +103,7 @@ def test_audio_to_text(omni_runner, omni_runner_handler) -> None: @pytest.mark.core_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True) def test_video_to_text(omni_runner, omni_runner_handler) -> None: """ @@ -120,7 +120,7 @@ def test_video_to_text(omni_runner, omni_runner_handler) -> None: @pytest.mark.core_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params_thinker, indirect=True) def test_mixed_to_text(omni_runner, omni_runner_handler) -> None: """ @@ -140,7 +140,7 @@ def test_mixed_to_text(omni_runner, omni_runner_handler) -> None: @pytest.mark.advanced_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params, indirect=True) def test_text_to_audio(omni_runner, omni_runner_handler) -> None: """ @@ -156,7 +156,7 @@ def test_text_to_audio(omni_runner, omni_runner_handler) -> None: @pytest.mark.advanced_model @pytest.mark.omni -@hardware_test(res={"cuda": "H100"}, num_cards=4) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=4) @pytest.mark.parametrize("omni_runner", test_params, indirect=True) def test_image_to_audio(omni_runner, omni_runner_handler) -> None: """ diff --git a/tests/e2e/offline_inference/test_ming_tts.py b/tests/e2e/offline_inference/test_ming_tts.py new file mode 100644 index 000000000..ca76c9afe --- /dev/null +++ b/tests/e2e/offline_inference/test_ming_tts.py @@ -0,0 +1,295 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""End-to-end offline inference tests for Ming-omni-tts.""" + +import asyncio +import uuid + +import numpy as np +import pytest +import torch +from transformers import AutoTokenizer +from vllm import SamplingParams +from vllm.inputs import tokens_input + +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 import AsyncOmni +from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( + KEY_MAX_DECODE_STEPS, + SAMPLE_RATE, + TEXT_EOS_TOKEN_ID, +) +from vllm_omni.model_executor.models.ming_tts.prompt_assembly import DEFAULT_PROMPT, build_ming_dense_prompt + +MODEL = "inclusionAI/Ming-omni-tts-0.5B" +DEPLOY_CONFIG = get_deploy_config_path("ming_tts.yaml") +TEST_TEXT = "我会一直在这里陪着你,直到你慢慢地沉入那个最温柔的梦里。" +TEST_INSTRUCTION = "轻柔的ASMR耳语,慢速,贴近麦克风" +MIN_AUDIO_SAMPLES = 1000 + + +@pytest.fixture(scope="module") +def ming_tokenizer(): + return AutoTokenizer.from_pretrained(MODEL, trust_remote_code=False) + + +@pytest.fixture +def ming_engine(): + with OmniRunner( + MODEL, + deploy_config=DEPLOY_CONFIG, + stage_init_timeout=300, + enforce_eager=True, + ) as runner: + yield runner.omni + + +@pytest.fixture +def async_omni_engine(): + engine = AsyncOmni( + model=MODEL, + deploy_config=DEPLOY_CONFIG, + stage_init_timeout=300, + enforce_eager=True, + ) + yield engine + engine.shutdown() + + +def _build_prompt( + tokenizer, + *, + text: str = TEST_TEXT, + instruction=TEST_INSTRUCTION, + use_zero_spk_emb: bool = True, +) -> dict: + prompt_dict = build_ming_dense_prompt( + tokenizer, + prompt=DEFAULT_PROMPT, + text=text, + instruction=instruction, + runtime_controls={KEY_MAX_DECODE_STEPS: 200}, + use_zero_spk_emb=use_zero_spk_emb, + ) + 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 + + +def _sampling_params_list() -> list[SamplingParams]: + return [ + SamplingParams( + temperature=0.0, + max_tokens=201, + stop_token_ids=[int(TEXT_EOS_TOKEN_ID)], + ), + SamplingParams(temperature=0.0, max_tokens=1), + ] + + +def _flatten_audio(audio) -> torch.Tensor: + if isinstance(audio, list): + parts = [torch.as_tensor(item, dtype=torch.float32).reshape(-1).cpu() for item in audio] + parts = [item for item in parts if item.numel() > 0] + if not parts: + return torch.zeros((0,), dtype=torch.float32) + return torch.cat(parts, dim=0) + return torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu() + + +def _extract_audio(multimodal_output: dict) -> torch.Tensor: + assert isinstance(multimodal_output, dict), f"Expected dict, got {type(multimodal_output)}" + audio = multimodal_output.get("audio", multimodal_output.get("model_outputs")) + assert audio is not None, f"No audio output found, keys={list(multimodal_output.keys())}" + waveform = _flatten_audio(audio) + if waveform.numel() == 0: + raise RuntimeError("Generated audio waveform is empty") + return waveform + + +def _extract_sample_rate(multimodal_output: dict) -> int: + sample_rate = multimodal_output.get("sr") + if sample_rate is None: + raise RuntimeError("Expected multimodal_output['sr']") + if isinstance(sample_rate, list): + sample_rate = sample_rate[-1] + if hasattr(sample_rate, "item"): + sample_rate = sample_rate.item() + return int(sample_rate) + + +def _extract_final_audio_outputs(outputs): + final_outputs = [] + for item in outputs: + if getattr(item, "final_output_type", None) == "audio": + final_outputs.append(item) + continue + request_output = getattr(item, "request_output", None) + if request_output is None: + continue + multimodal_output = getattr(request_output, "multimodal_output", None) + if isinstance(multimodal_output, dict): + final_outputs.append(item) + continue + completions = getattr(request_output, "outputs", None) or [] + if any(isinstance(getattr(completion, "multimodal_output", None), dict) for completion in completions): + final_outputs.append(item) + return final_outputs + + +def _extract_multimodal_output(output) -> dict: + multimodal_output = getattr(output, "multimodal_output", None) + if isinstance(multimodal_output, dict): + return multimodal_output + + request_output = getattr(output, "request_output", None) + if request_output is not None: + multimodal_output = getattr(request_output, "multimodal_output", None) + if isinstance(multimodal_output, dict): + return multimodal_output + completions = getattr(request_output, "outputs", None) or [] + for completion in completions: + multimodal_output = getattr(completion, "multimodal_output", None) + if isinstance(multimodal_output, dict): + return multimodal_output + + raise AssertionError("No multimodal audio output found in Ming generate results") + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_ming_tts_offline_basic(ming_engine, ming_tokenizer) -> None: + """Test blocking Ming generation through Omni.""" + outputs = ming_engine.generate( + prompts=[_build_prompt(ming_tokenizer)], + sampling_params_list=_sampling_params_list(), + py_generator=False, + ) + final_outputs = _extract_final_audio_outputs(outputs) + assert len(final_outputs) == 1, f"Expected one final audio output, got {len(final_outputs)}" + multimodal_output = _extract_multimodal_output(final_outputs[0]) + waveform = _extract_audio(multimodal_output) + sample_rate = _extract_sample_rate(multimodal_output) + assert waveform.ndim == 1 + assert waveform.shape[0] == waveform.numel() + assert waveform.numel() > MIN_AUDIO_SAMPLES + assert np.max(np.abs(waveform.numpy())) > 0.01, "Audio appears silent" + assert sample_rate == SAMPLE_RATE, f"Expected Ming output sample rate {SAMPLE_RATE}, got {sample_rate}" + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_ming_tts_speaker_conditioning_differs(ming_engine, ming_tokenizer) -> None: + """Test that different Ming speaker controls produce different waveform outputs.""" + style_outputs = ming_engine.generate( + prompts=[_build_prompt(ming_tokenizer)], + sampling_params_list=_sampling_params_list(), + py_generator=False, + ) + ip_outputs = ming_engine.generate( + prompts=[_build_prompt(ming_tokenizer, text=TEST_TEXT, instruction={"IP": "灵小甄"}, use_zero_spk_emb=True)], + sampling_params_list=_sampling_params_list(), + py_generator=False, + ) + + style_final_outputs = _extract_final_audio_outputs(style_outputs) + ip_final_outputs = _extract_final_audio_outputs(ip_outputs) + assert len(style_final_outputs) == 1, "No style audio output produced" + assert len(ip_final_outputs) == 1, "No IP audio output produced" + + style_waveform = _extract_audio(_extract_multimodal_output(style_final_outputs[0])) + ip_waveform = _extract_audio(_extract_multimodal_output(ip_final_outputs[0])) + assert style_waveform.numel() > MIN_AUDIO_SAMPLES + assert ip_waveform.numel() > MIN_AUDIO_SAMPLES + assert np.max(np.abs(style_waveform.numpy())) > 0.01, "Style audio appears silent" + assert np.max(np.abs(ip_waveform.numpy())) > 0.01, "IP audio appears silent" + + overlap = min(int(style_waveform.numel()), int(ip_waveform.numel())) + mean_abs_diff = torch.mean(torch.abs(style_waveform[:overlap] - ip_waveform[:overlap])).item() + assert style_waveform.shape != ip_waveform.shape or mean_abs_diff > 1e-4, ( + "Speaker-conditioned outputs should differ, but style and IP waveforms were effectively identical" + ) + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_ming_tts_multiple_prompts_queued(ming_engine, ming_tokenizer) -> None: + """Regression: supported max_num_seqs=1 config must still drain queued prompts.""" + prompts = [ + _build_prompt( + ming_tokenizer, + text="第一条语音用于验证 Ming dense 队列中的长请求可以完成。", + instruction="平静自然的旁白,语速中等", + ), + _build_prompt( + ming_tokenizer, + text="第二条短语音也必须生成完成。", + instruction="清晰明亮的女声", + ), + ] + outputs = ming_engine.generate( + prompts=prompts, + sampling_params_list=_sampling_params_list(), + py_generator=False, + ) + final_outputs = _extract_final_audio_outputs(outputs) + assert len(final_outputs) == len(prompts), f"Expected {len(prompts)} audio outputs, got {len(final_outputs)}" + for i, output in enumerate(final_outputs): + waveform = _extract_audio(_extract_multimodal_output(output)) + duration_s = waveform.numel() / SAMPLE_RATE + assert 0.1 < duration_s < 30.0, f"Request {i} audio duration out of range: {duration_s:.2f}s" + assert np.max(np.abs(waveform.numpy())) > 0.01, f"Request {i} audio appears silent" + + +@pytest.mark.advanced_model +@pytest.mark.omni +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_ming_tts_offline_streaming(async_omni_engine, ming_tokenizer) -> None: + """Test async_chunk streaming Ming generation through AsyncOmni.""" + + async def _run() -> None: + all_audio_chunks = [] + accumulated_samples = 0 + chunk_idx = 0 + sample_rate = None + async for stage_output in async_omni_engine.generate( + prompt=_build_prompt(ming_tokenizer), + request_id=str(uuid.uuid4()), + sampling_params_list=_sampling_params_list(), + ): + multimodal_output = stage_output.multimodal_output or {} + audio = multimodal_output.get("audio") + if "sr" in multimodal_output: + sample_rate = _extract_sample_rate(multimodal_output) + if audio is None: + continue + finished = stage_output.finished + if isinstance(audio, torch.Tensor): + if finished: + audio_chunk = audio[accumulated_samples:].float().detach().cpu() + else: + audio_chunk = audio.float().detach().cpu() + elif isinstance(audio, list): + audio_chunk = _flatten_audio(audio) + else: + audio_chunk = torch.as_tensor(audio, dtype=torch.float32).reshape(-1).cpu() + accumulated_samples += int(audio_chunk.numel()) + chunk_idx += 1 + if audio_chunk.numel() > 0: + all_audio_chunks.append(audio_chunk) + assert all_audio_chunks, "No streaming audio chunks received" + waveform = torch.cat(all_audio_chunks, dim=0) + assert waveform.numel() > MIN_AUDIO_SAMPLES + assert np.max(np.abs(waveform.numpy())) > 0.01, "Audio appears silent" + assert sample_rate is not None, "Streaming path did not return a sample rate" + assert sample_rate == SAMPLE_RATE, f"Expected Ming output sample rate {SAMPLE_RATE}, got {sample_rate}" + + asyncio.run(_run()) diff --git a/tests/e2e/online_serving/test_ming_tts.py b/tests/e2e/online_serving/test_ming_tts.py new file mode 100644 index 000000000..03982cc4a --- /dev/null +++ b/tests/e2e/online_serving/test_ming_tts.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +E2E online serving tests for Ming-omni-tts (dense) model. +Tests text-to-audio via /v1/audio/speech endpoint. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.advanced_model, pytest.mark.omni] + +MODEL = "inclusionAI/Ming-omni-tts-0.5B" +DEPLOY_CONFIG = get_deploy_config_path("ming_tts.yaml") + +no_async_chunk_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + server_args=["--enforce-eager", "--no-async-chunk"], + ), + id="no_async_chunk", + ) +] + +async_chunk_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=DEPLOY_CONFIG, + server_args=["--enforce-eager"], + ), + id="async_chunk", + ) +] + + +def get_prompt(prompt_type="zh"): + prompts = { + "zh": "今天天气真不错,适合出去散散步。", + "zh_short": "这款产品的名字,叫变态坑爹牛肉丸。", + } + return prompts.get(prompt_type, prompts["zh"]) + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", no_async_chunk_params, indirect=True) +def test_text_to_audio_non_streaming_001(omni_server, openai_client) -> None: + """ + Deploy Setting: ming_tts.yaml with --no-async-chunk + Input Modal: text + Output Modal: audio + Input Setting: stream=False + Datasets: two concurrent requests + """ + request_config = { + "model": omni_server.model, + "input": get_prompt("zh"), + "stream": False, + "response_format": "wav", + "timeout": 300.0, + } + openai_client.send_audio_speech_request(request_config, request_num=2) + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", async_chunk_params, indirect=True) +def test_text_to_audio_streaming_001(omni_server, openai_client) -> None: + """ + Deploy Setting: ming_tts.yaml (async_chunk=true) + Input Modal: text + voice + Output Modal: audio (streamed) + Input Setting: stream=True + Datasets: single request + """ + request_config = { + "model": omni_server.model, + "input": get_prompt("zh_short"), + "voice": "灵小甄", + "stream": True, + "response_format": "wav", + "timeout": 300.0, + } + openai_client.send_audio_speech_request(request_config) 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 new file mode 100644 index 000000000..7aab6133b --- /dev/null +++ b/tests/model_executor/models/ming_tts/test_ming_shared_modules.py @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig +from vllm_omni.model_executor.models.common.ming.fm import Solver +from vllm_omni.model_executor.models.ming_tts.constants import ( + AGGREGATOR_HIDDEN_SIZE, + HISTORY_PATCH_SIZE, + LATENT_DIM, + LLM_HIDDEN_SIZE, + LLM_VOCAB_SIZE, + PATCH_SIZE, + SAMPLE_RATE, + VAE_PATCH_SIZE, +) +from vllm_omni.model_executor.models.ming_tts.flowloss_head import FlowLoss +from vllm_omni.model_executor.models.ming_tts.ming_tts_llm import MingLLMModel +from vllm_omni.model_executor.models.ming_tts.validation import validate_ming_tts_config +from vllm_omni.model_executor.models.output_templates import OmniOutput + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.tts] + + +def test_ming_tts_audio_vae_uses_common_config(): + """AudioVAEConfig is shared by Ming dense and Ming flash modules.""" + cfg = AudioVAEConfig(sample_rate=16000, patch_size=-1) + + assert cfg.sample_rate == 16000 + assert cfg.patch_size == -1 + + +def test_ming_tts_cfm_solver_uses_common_implementation(): + """Ming dense imports the shared solver implementation directly.""" + assert Solver.__module__ == "vllm_omni.model_executor.models.common.ming.fm" + + +def test_ming_tts_flowloss_preserves_checkpoint_prefix(): + flowloss = FlowLoss(z_channels=4, llm_cond_dim=8, hidden_size=16, depth=1, num_heads=2) + + assert any(name.startswith("cfm.model.") for name in flowloss.state_dict()) + + +def test_ming_dense_validation_rejects_semantic_audio_vae_config(): + """Dense 0.5B validation rejects semantic AudioVAE configs.""" + cfg = SimpleNamespace( + audio_dummy_token_id=151705, + audio_eos_token_id=151704, + text_eos_token_id=151669, + audio_tokenizer_config=AudioVAEConfig( + sample_rate=SAMPLE_RATE, + patch_size=VAE_PATCH_SIZE, + semantic_module_kwargs={"whisper_encoder": {}}, + enc_kwargs={"latent_dim": LATENT_DIM, "input_dim": 882, "hop_size": 882}, + dec_kwargs={"latent_dim": LATENT_DIM, "output_dim": 882}, + ), + latent_dim=LATENT_DIM, + patch_size=PATCH_SIZE, + history_patch_size=HISTORY_PATCH_SIZE, + llm_hidden_size=LLM_HIDDEN_SIZE, + llm_vocab_size=LLM_VOCAB_SIZE, + sample_rate=SAMPLE_RATE, + vae_patch_size=VAE_PATCH_SIZE, + llm_config={"hidden_size": LLM_HIDDEN_SIZE}, + aggregator_config={"hidden_size": AGGREGATOR_HIDDEN_SIZE}, + ditar_config={"hidden_size": AGGREGATOR_HIDDEN_SIZE}, + latent_chunk_size=1, + latent_left_context=0, + max_decode_steps=1, + stop_head_threshold=0.5, + stop_head_min_steps=0, + ) + + with pytest.raises(ValueError, match="semantic_module_kwargs"): + validate_ming_tts_config(cfg) + + +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": {}} + + dense_plain = serving._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None)) + assert dense_plain == "calm" + + dense_with_fields = serving._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="灵小甄") + ) + assert flash_fields == {"风格": "calm"} + + +def _make_ming_logits_model(vocab_size=8): + model = object.__new__(MingLLMModel) + model.ming_config = SimpleNamespace( + llm_vocab_size=vocab_size, + max_decode_steps=1, + stop_head_min_steps=0, + text_eos_token_id=7, + ) + model._last_text_mode = False + model._last_ming_next_token_ids = None + return model + + +def test_ming_compute_logits_uses_cached_forced_next_token_ids(): + model = _make_ming_logits_model() + model._last_ming_next_token_ids = [2, 5] + + logits = MingLLMModel.compute_logits(model, torch.zeros((2, 4)), SimpleNamespace()) + + assert logits.shape == (2, 8) + assert logits[0, 2].item() == 0.0 + assert logits[1, 5].item() == 0.0 + assert torch.isneginf(logits[0, [0, 1, 3, 4, 5, 6, 7]]).all() + assert torch.isneginf(logits[1, [0, 1, 2, 3, 4, 6, 7]]).all() + assert model._last_ming_next_token_ids is None + + +def test_ming_compute_logits_falls_back_to_dummy_token_id(): + model = _make_ming_logits_model() + + logits = MingLLMModel.compute_logits(model, torch.zeros((1, 4)), SimpleNamespace()) + assert logits.shape == (1, 8) + assert logits[0, 7].item() == 0.0 + assert torch.isneginf(logits[0, [0, 1, 2, 3, 4, 5, 6]]).all() + + +def test_ming_forward_non_decode_return_clears_cached_forced_next_token_ids(): + class FakeBackbone: + def __call__(self, **kwargs): + return kwargs["inputs_embeds"] + + model = _make_ming_logits_model() + model.model = FakeBackbone() + model._last_ming_next_token_ids = [2] + + output = MingLLMModel.forward( + model, + input_ids=torch.tensor([1]), + positions=torch.tensor([0]), + inputs_embeds=torch.zeros((1, 4)), + model_intermediate_buffer=[], + ) + + assert isinstance(output, OmniOutput) + assert output.multimodal_outputs is None + assert model._last_ming_next_token_ids is None + + +def test_ming_compute_logits_rejects_forced_token_batch_mismatch(): + model = _make_ming_logits_model() + model._last_ming_next_token_ids = [2] + + with pytest.raises(RuntimeError, match="batch mismatch"): + MingLLMModel.compute_logits(model, torch.zeros((2, 4)), SimpleNamespace()) + + +def test_ming_compute_logits_text_mode_delegates_to_backbone(): + class FakeBackbone: + def __init__(self): + self.hidden_states = None + + def compute_logits(self, hidden_states): + self.hidden_states = hidden_states + return torch.ones((hidden_states.shape[0], 3)) + + model = _make_ming_logits_model(vocab_size=3) + model._last_text_mode = True + model.model = FakeBackbone() + hidden_states = torch.zeros((2, 4)) + + logits = MingLLMModel.compute_logits(model, hidden_states, SimpleNamespace()) + + assert torch.equal(model.model.hidden_states, hidden_states) + assert torch.equal(logits, torch.ones((2, 3))) diff --git a/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py index 4b2bdf3fe..52ca29905 100644 --- a/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py +++ b/tests/model_executor/stage_input_processors/test_qwen3_omni_streaming_helpers.py @@ -921,6 +921,7 @@ def test_cosyvoice3_full_payload_replace_keys_present() -> None: def test_ming_flash_omni_thinker2talker_token_only_smoke() -> None: """Smoke: ming_flash_omni token-only carries voice metadata.""" from vllm_omni.model_executor.stage_input_processors.ming_flash_omni import ( + thinker2talker, thinker2talker_token_only, ) @@ -938,14 +939,15 @@ def test_ming_flash_omni_thinker2talker_token_only_smoke() -> None: src = [_Wrap("hello world")] prompt = _Prompt({"voice_name": "ZH_FEMALE", "prompt_text": "ref text"}) - out = thinker2talker_token_only(src, prompt=prompt) - assert len(out) == 1 - assert out[0]["prompt_token_ids"] == [0] # talker self-tokenizes; dummy id - info = out[0]["additional_information"] - assert info["text"] == "hello world" - assert info["voice_name"] == "ZH_FEMALE" - assert info["prompt_text"] == "ref text" - assert info["ming_task"] == "omni" + for func in (thinker2talker, thinker2talker_token_only): + out = func(src, prompt=prompt) + assert len(out) == 1 + assert out[0]["prompt_token_ids"] == [0] + info = out[0]["additional_information"] + assert info["text"] == "hello world" + assert info["voice_name"] == "ZH_FEMALE" + assert info["prompt_text"] == "ref text" + assert info["ming_task"] == "omni" def test_qwen2_5_omni_thinker2talker_token_only_smoke() -> None: diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index a12e6bcd6..106b2bf12 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -101,6 +101,10 @@ _OMNI_PIPELINES: dict[str, tuple[str, str]] = { "vllm_omni.model_executor.models.mimo_audio.pipeline", "MIMO_AUDIO_PIPELINE", ), + "ming_tts": ( + "vllm_omni.model_executor.models.ming_tts.pipeline", + "MING_TTS_PIPELINE", + ), "voxtral_tts": ( "vllm_omni.model_executor.models.voxtral_tts.pipeline", "VOXTRAL_TTS_PIPELINE", diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index 43e00a142..1bff5ef94 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -1128,6 +1128,19 @@ class StageConfigFactory: continue return cls._create_from_registry(registered.model_type, cli_overrides, deploy_config_path) + # An explicit deploy config can select a pipeline when the model's HF + # metadata is too generic for registry auto-detection. + if deploy_config_path is not None: + deploy_path = Path(deploy_config_path) + if deploy_path.exists(): + deploy_config = load_deploy_config(deploy_path) + if deploy_config.pipeline in _PIPELINE_REGISTRY: + return cls._create_from_registry( + deploy_config.pipeline, + cli_overrides, + deploy_config_path, + ) + # Not in the pipeline registry — let the caller fall back to the # legacy ``stage_configs/*.yaml`` path (resolve_model_config_path). return None diff --git a/vllm_omni/deploy/ming_tts.yaml b/vllm_omni/deploy/ming_tts.yaml new file mode 100644 index 000000000..1a2dc99ec --- /dev/null +++ b/vllm_omni/deploy/ming_tts.yaml @@ -0,0 +1,55 @@ +# Ming-omni-tts deploy: stage-0 LLM+flow -> stage-1 audio VAE. +# Verified legacy settings migrated from ming_tts.yaml and ming_tts_async_chunk.yaml. +# Default mode is async-chunk streaming on a single GPU. +# pipeline: required — HF config reports model_type="dense" with no architectures, +# so auto-detection falls through. This key routes the deploy-config probe in +# StageConfigFactory.create_from_model to the correct registry entry. +pipeline: ming_tts +async_chunk: true +trust_remote_code: false +dtype: bfloat16 +enable_prefix_caching: false + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + latent_chunk_size: 25 + latent_left_context: 0 + +stages: + - stage_id: 0 + max_num_seqs: 1 + gpu_memory_utilization: 0.45 + enforce_eager: true + enable_prefix_caching: false + async_scheduling: false + max_num_batched_tokens: 8192 + max_model_len: 8192 + devices: "0" + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 512 + detokenize: true + + - stage_id: 1 + max_num_seqs: 1 + gpu_memory_utilization: 0.25 + enforce_eager: true + enable_prefix_caching: false + async_scheduling: false + max_num_batched_tokens: 8192 + max_model_len: 8192 + 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: 1 + detokenize: false diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 25244c462..ac52de953 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -35,6 +35,7 @@ def _register_omni_hf_configs() -> None: try: from transformers import AutoConfig + from vllm_omni.model_executor.models.ming_tts.config_ming_tts import MingDenseConfig from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import ( Qwen3TTSConfig, ) @@ -55,6 +56,7 @@ def _register_omni_hf_configs() -> None: _CONFIG_REGISTRY = None for model_type, config_cls in [ + ("dense", MingDenseConfig), ("qwen3_tts", Qwen3TTSConfig), ("cosyvoice3", CosyVoice3Config), ("glm_tts", GLMTTSConfig), diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index a18687ca2..1dce789ce 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -7,6 +7,43 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_vali _MAX_EMBEDDING_DIM = 8192 +def _normalize_ref_audio_value(value): + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, (list, tuple)): + items = [] + for item in value: + if not isinstance(item, str): + raise TypeError("'ref_audio' list entries must be strings") + items.append(item) + if not items: + raise ValueError("'ref_audio' list cannot be empty") + return items + raise TypeError("'ref_audio' must be a string or list of strings") + + +def _normalize_speaker_embedding_value(value): + if value is None: + return None + if not isinstance(value, (list, tuple)): + raise TypeError("'speaker_embedding' must be a list of numbers or list of embedding vectors") + if not value: + return [] + + first = value[0] + if isinstance(first, (list, tuple)): + embeddings = [] + for item in value: + if not isinstance(item, (list, tuple)): + raise TypeError("'speaker_embedding' must not mix flat and nested values") + embeddings.append([float(x) for x in item]) + return embeddings + + return [float(x) for x in value] + + class OpenAICreateSpeechRequest(BaseModel): input: str model: str | None = None @@ -46,7 +83,7 @@ class OpenAICreateSpeechRequest(BaseModel): default=None, description="Language code (e.g., 'Chinese', 'English', 'Auto')", ) - ref_audio: str | None = Field( + ref_audio: str | list[str] | None = Field( default=None, description="Reference audio for voice cloning (Base task). URL, base64, or file URI.", ) @@ -73,7 +110,7 @@ class OpenAICreateSpeechRequest(BaseModel): default=None, description="Use speaker embedding only without in-context learning (Base task)", ) - speaker_embedding: list[float] | None = Field( + speaker_embedding: list[float] | list[list[float]] | None = Field( default=None, max_length=_MAX_EMBEDDING_DIM, description="Pre-computed speaker embedding vector (1024-dim for 0.6B, " @@ -108,10 +145,29 @@ class OpenAICreateSpeechRequest(BaseModel): raise ValueError("'sse' is not a supported stream_format yet. Please use 'audio'.") return v + @field_validator("ref_audio", mode="before") + @classmethod + def normalize_ref_audio(cls, v): + return _normalize_ref_audio_value(v) + @field_validator("speaker_embedding") @classmethod - def validate_speaker_embedding(cls, v: list[float] | None) -> list[float] | None: - if v is not None and not all(math.isfinite(x) for x in v): + def validate_speaker_embedding( + cls, v: list[float] | list[list[float]] | None + ) -> list[float] | list[list[float]] | None: + v = _normalize_speaker_embedding_value(v) + if v is None: + return None + if not v: + return [] + if isinstance(v[0], list): + for item in v: + if not item: + raise ValueError("'speaker_embedding' nested vectors must be non-empty") + if not all(math.isfinite(x) for x in item): + raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)") + return v + if not all(math.isfinite(x) for x in v): raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)") return v @@ -210,7 +266,7 @@ class OpenAICreateSpeechRequest(BaseModel): @model_validator(mode="after") def validate_embedding_constraints(self) -> "OpenAICreateSpeechRequest": if self.speaker_embedding is not None: - if self.ref_audio is not None: + if self.ref_audio is not None and not isinstance(self.ref_audio, list): raise ValueError("'speaker_embedding' and 'ref_audio' are mutually exclusive") return self diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index d24181a0f..9127ac7db 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -66,6 +66,7 @@ from vllm_omni.utils.speaker_cache import ( logger = init_logger(__name__) # TTS Configuration +_MING_TTS_MODEL_ARCHS = {"MingTTSForConditionalGeneration"} _VOXTRAL_TTS_MODEL_STAGES = {"audio_generation"} _QWEN3_TTS_MODEL_STAGES = {"qwen3_tts"} _FISH_TTS_MODEL_STAGES = {"fish_speech_slow_ar"} @@ -126,6 +127,7 @@ _QWEN3_TTS_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key" _TTS_MAX_INSTRUCTIONS_LENGTH = 500 _TTS_MAX_NEW_TOKENS_MIN = 1 _TTS_MAX_NEW_TOKENS_MAX = 4096 +_MING_DEFAULT_PROMPT = MING_DEFAULT_PROMPT def _create_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16) -> bytes: @@ -528,6 +530,32 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def _load_codec_frame_rate(self) -> float | None: """Load codec frame rate from speech tokenizer config for prompt length estimation.""" + if self._tts_model_type == "ming_tts": + try: + from vllm_omni.model_executor.models.ming_tts.config_ming_tts import MingTTSConfig + + hf_config = self.engine_client.model_config.hf_config + ming_cfg = MingTTSConfig.from_hf_config(hf_config) + patch_size = int(ming_cfg.patch_size) + audio_frame_hop = int(ming_cfg.audio_frame_hop) + sample_rate = int(ming_cfg.sample_rate) + if patch_size <= 0 or audio_frame_hop <= 0 or sample_rate <= 0: + raise ValueError( + "Ming config has invalid tokenizer timing values: " + f"patch_size={patch_size}, audio_frame_hop={audio_frame_hop}, sample_rate={sample_rate}" + ) + rate = float(sample_rate) / float(audio_frame_hop * patch_size) + logger.info( + "Derived Ming codec frame rate: %.1f Hz (sample_rate=%s, audio_frame_hop=%s, patch_size=%s)", + rate, + sample_rate, + audio_frame_hop, + patch_size, + ) + return rate + except Exception as e: + logger.warning(f"Failed to derive Ming codec frame rate from hf_config: {e}") + try: model_path = self.engine_client.model_config.model st_config_path = os.path.join(model_path, "speech_tokenizer", "config.json") @@ -572,7 +600,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def _find_tts_stage(self): """Find and return the TTS stage config, or None if not found.""" for stage in self.engine_client.stage_configs: - if stage.engine_args.model_stage in _TTS_MODEL_STAGES: + engine_args = stage.engine_args + model_stage = engine_args.model_stage + model_arch = getattr(engine_args, "model_arch", None) + worker_type = getattr(engine_args, "worker_type", None) + if model_stage in _TTS_MODEL_STAGES: + return stage + # Ming dense identifies its AR entry stage by architecture because + # it does not use a dedicated TTS model_stage value. + if model_arch in _MING_TTS_MODEL_ARCHS and worker_type == "ar": return stage return None @@ -602,6 +638,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return "voxcpm2" if model_stage in _MING_TTS_MODEL_STAGES: return "ming_flash_omni_tts" + if model_arch in _MING_TTS_MODEL_ARCHS: + return "ming_tts" if model_stage in _MOSS_TTS_MODEL_STAGES: return "moss_tts_nano" if model_stage in _MOSS_TTS_FULL_MODEL_STAGES: @@ -682,6 +720,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): try: if self._tts_model_type == "glm_tts": return set() + if self._tts_model_type == "ming_tts": + return set() if self._tts_model_type == "voxcpm2": return {"default"} if self._tts_model_type == "voxtral_tts": @@ -1214,9 +1254,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): raise ValueError("'speaker_embedding' values must be finite (no NaN or Inf)") emb_dim = len(embedding) - dim_err = self._validate_qwen_tts_speaker_embedding_dim(emb_dim) - if dim_err is not None: - raise ValueError(dim_err) + if self._tts_model_type == "ming_tts": + if emb_dim != 192: + logger.warning("speaker_embedding has %d dimensions; Ming dense expects 192", emb_dim) + else: + dim_err = self._validate_qwen_tts_speaker_embedding_dim(emb_dim) + if dim_err is not None: + raise ValueError(dim_err) async with self._upload_lock: voice_name_lower = name.lower() @@ -1303,7 +1347,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def _is_tts_model(self) -> bool: """Check if the current model is a supported TTS model.""" - return any(stage.engine_args.model_stage in _TTS_MODEL_STAGES for stage in self.engine_client.stage_configs) + return self._find_tts_stage() is not None def _validate_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: """Validate TTS request parameters. Returns error message or None.""" @@ -1316,6 +1360,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if self._tts_model_type == "voxcpm2": return self._validate_voxcpm2_request(request) if self._tts_model_type == "ming_flash_omni_tts": + return self._validate_ming_flash_omni_tts_request(request) + if self._tts_model_type == "ming_tts": return self._validate_ming_tts_request(request) if self._tts_model_type in ("moss_tts_nano", "moss_tts"): return self._validate_moss_tts_request(request) @@ -1362,7 +1408,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): ids = self._voxcpm2_tokenizer.encode(text, add_special_tokens=True) return split_multichar_chinese(ids, self._voxcpm2_split_map) - def _validate_ming_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: + def _validate_ming_flash_omni_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: """Validate Ming-flash-omni standalone-talker request parameters.""" if not request.input or not request.input.strip(): return "Input text cannot be empty" @@ -1399,6 +1445,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): 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): + return "ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)" if not ( ref_audio.startswith(("http://", "https://")) or ref_audio.startswith("data:") @@ -2066,6 +2114,99 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return None + def _validate_ming_tts_request(self, request: OpenAICreateSpeechRequest) -> str | None: + """Validate Ming TTS request parameters. Returns error message or None.""" + if not request.input or not request.input.strip(): + return "Input text cannot be empty" + + if isinstance(request.ref_audio, list): + return self._validate_ming_tts_podcast_request(request) + return self._validate_ming_tts_single_speaker_request(request) + + def _validate_ming_tts_single_speaker_request(self, request: OpenAICreateSpeechRequest) -> str | None: + if request.ref_audio is not None: + fmt_err = self._validate_ref_audio_format(request.ref_audio) + if fmt_err: + return fmt_err + + if request.speaker_embedding is not None: + if not request.speaker_embedding: + return "'speaker_embedding' must be a non-empty list of floats" + emb_len = len(request.speaker_embedding) + if emb_len != 192: + logger.warning( + "speaker_embedding has %d dimensions; Ming dense expects 192. " + "Wrong dimensions will likely fail or degrade output.", + emb_len, + ) + + voice_lower = request.voice.lower() if isinstance(request.voice, str) else None + uploaded_voice = bool(voice_lower and voice_lower in self.uploaded_speakers) + clone_source_present = request.ref_audio is not None or request.speaker_embedding is not None or uploaded_voice + + if request.task_type == "Base" and not clone_source_present: + return "Base task requires 'ref_audio', 'speaker_embedding', or an uploaded voice sample" + + if request.ref_audio is not None and request.ref_text is not None and not request.ref_text.strip(): + return "'ref_text' must be non-empty when provided with 'ref_audio'" + + # Ming offline ref-audio cases use prompt_waveform without prompt_text; + # keep the transcript requirement for other TTS models. + if request.ref_audio is not None and request.speaker_embedding is None and not self._is_ming_tts_model(): + uploaded_ref_text = self.uploaded_speakers[voice_lower].get("ref_text") if uploaded_voice else None + if not (request.ref_text and request.ref_text.strip()) and not uploaded_ref_text: + return "Reference-audio cloning requires non-empty 'ref_text'" + + if request.ref_text is not None and request.ref_audio is None and not uploaded_voice: + return "'ref_text' requires 'ref_audio' or an uploaded voice sample" + + if request.instructions and len(request.instructions) > self._max_instructions_length: + return f"Instructions too long (max {self._max_instructions_length} characters)" + + if request.max_new_tokens is not None: + if request.max_new_tokens < _TTS_MAX_NEW_TOKENS_MIN: + return f"max_new_tokens must be at least {_TTS_MAX_NEW_TOKENS_MIN}" + if request.max_new_tokens > _TTS_MAX_NEW_TOKENS_MAX: + return f"max_new_tokens cannot exceed {_TTS_MAX_NEW_TOKENS_MAX}" + + return None + + def _validate_ming_tts_podcast_request(self, request: OpenAICreateSpeechRequest) -> str | None: + if len(request.ref_audio) < 2: + return "Podcast-style Ming requests require at least two 'ref_audio' clips" + + for ref_audio in request.ref_audio: + fmt_err = self._validate_ref_audio_format(ref_audio) + if fmt_err: + return fmt_err + + if not request.ref_text or not request.ref_text.strip(): + return "Podcast-style Ming requests require non-empty 'ref_text'" + + if request.speaker_embedding is not None: + embeddings = request.speaker_embedding + embedding_count = len(embeddings) if embeddings and isinstance(embeddings[0], list) else 1 + if embedding_count != len(request.ref_audio): + return ( + "Podcast-style Ming requests require one speaker embedding per ref_audio clip; " + f"got {embedding_count} embeddings for {len(request.ref_audio)} clips" + ) + if embeddings and isinstance(embeddings[0], list): + for item in embeddings: + if len(item) != 192: + return "Podcast-style Ming speaker embeddings must each have 192 dimensions" + + if request.instructions and len(request.instructions) > self._max_instructions_length: + return f"Instructions too long (max {self._max_instructions_length} characters)" + + if request.max_new_tokens is not None: + if request.max_new_tokens < _TTS_MAX_NEW_TOKENS_MIN: + return f"max_new_tokens must be at least {_TTS_MAX_NEW_TOKENS_MIN}" + if request.max_new_tokens > _TTS_MAX_NEW_TOKENS_MAX: + return f"max_new_tokens cannot exceed {_TTS_MAX_NEW_TOKENS_MAX}" + + return None + async def _resolve_ref_audio(self, ref_audio_str: str) -> tuple[list[float], int]: """Resolve ref_audio to (wav_samples, sample_rate). @@ -2192,6 +2333,142 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def _discard_ref_audio_artifact_warmup(self, request_id: str) -> None: self._request_ref_audio_artifact_keys.pop(request_id, None) + async def _resolve_ref_audio_many(self, ref_audio_list: list[str]) -> list[tuple[list[float], int]]: + resolved = [] + for ref_audio in ref_audio_list: + resolved.append(await self._resolve_ref_audio(ref_audio)) + return resolved + + # ---- Ming TTS helpers ---- + + def _is_ming_tts_model(self) -> bool: + return self._tts_model_type == "ming_tts" + + 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_prompt_waveform( + self, + ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None, + ): + if isinstance(ref_audio_data, list): + return torch.cat( + [self._coerce_ming_prompt_waveform(item[0], item[1]) for item in ref_audio_data], + dim=-1, + ) + if ref_audio_data is not None: + return self._coerce_ming_prompt_waveform(ref_audio_data[0], ref_audio_data[1]) + return None + + def _extract_ming_speaker_embeddings_from_ref_audio( + self, + ref_audio_data_list: list[tuple[list[float], int]], + ) -> list[list[float]]: + from vllm_omni.model_executor.models.ming_tts.speaker_extractor import MingSpeakerEmbeddingExtractor + + extractor = MingSpeakerEmbeddingExtractor(self.engine_client.model_config.model, target_sr=16000) + embeddings = [] + for wav_samples, sr in ref_audio_data_list: + waveform = torch.as_tensor(wav_samples, dtype=torch.float32).reshape(1, -1) + embedding = extractor.extract_from_waveform(waveform, int(sr)) + flat = embedding.detach().reshape(-1).to(torch.float32).cpu() + if int(flat.numel()) != 192: + raise ValueError(f"Ming speaker extractor returned {int(flat.numel())} dims; expected 192") + embeddings.append(flat.tolist()) + return embeddings + + def _parse_ming_instruction_fields( + self, + request, + *, + include_language=False, + include_voice=False, + plain_text_passthrough=False, + ): + instruction_text = request.instructions.strip() if isinstance(request.instructions, str) else None + instruction_dict: dict[str, Any] = {} + + if include_language and request.language not in (None, "", "Auto"): + instruction_dict["方言"] = request.language + + 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_language=True, + 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, + ) -> 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 + 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 + self._tts_tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=False) + + ref_text = request.ref_text + prompt_waveform = self._build_ming_prompt_waveform(ref_audio_data) if ref_text is not None else None + speaker_embedding = request.speaker_embedding + use_zero_spk_emb = 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, + use_zero_spk_emb=use_zero_spk_emb, + ) + 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, @@ -2796,25 +3073,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # ---- Ming-flash-omni standalone-talker (TTS) helpers ---- - def _build_ming_prompt(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: + 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: dict[str, Any] = {} - if request.instructions: - stripped = request.instructions.strip() - if stripped.startswith("{"): - try: - parsed = json.loads(stripped) - except json.JSONDecodeError: - parsed = None - if isinstance(parsed, dict): - caption_fields.update(parsed) - else: - caption_fields["风格"] = request.instructions - else: - caption_fields["风格"] = request.instructions + caption_fields = self._parse_ming_instruction_fields(request) or {} has_spk_emb = request.speaker_embedding is not None @@ -2982,8 +3246,29 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): prompt = await self._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio) tts_params = {} elif self._tts_model_type == "ming_flash_omni_tts": - prompt = self._build_ming_prompt(request) + prompt = self._build_ming_flash_omni_prompt(request) tts_params = {} + elif self._tts_model_type == "ming_tts": + ref_audio_source = request.ref_audio + voice_lower = request.voice.lower() if isinstance(request.voice, str) else None + if ref_audio_source is None and voice_lower in self.uploaded_speakers: + ref_audio_source = self._get_uploaded_audio_data(request.voice) + if request.ref_text is None: + request.ref_text = self.uploaded_speakers[voice_lower].get("ref_text") + ref_audio_data = None + if isinstance(ref_audio_source, list): + ref_audio_data = await self._resolve_ref_audio_many(ref_audio_source) + if request.speaker_embedding is None: + request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio(ref_audio_data) + elif ref_audio_source is not None and isinstance(ref_audio_source, str): + wav_list, sr = await self._resolve_ref_audio(ref_audio_source) + ref_audio_data = (wav_list, sr) + if request.speaker_embedding is None: + request.speaker_embedding = self._extract_ming_speaker_embeddings_from_ref_audio( + [ref_audio_data] + )[0] + prompt = self._build_ming_dense_prompt(request, ref_audio_data=ref_audio_data) + tts_params = prompt.get("additional_information", {}) elif self._tts_model_type in ("moss_tts_nano", "moss_tts"): tts_params = await self._build_moss_tts_params(request) if request.voice: @@ -3069,6 +3354,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): model_type = "voxcpm2" elif self._tts_model_type == "ming_flash_omni_tts": model_type = "ming_flash_omni_tts" + elif self._tts_model_type == "ming_tts": + model_type = "ming_tts" elif self._tts_model_type == "moss_tts_nano": model_type = "moss_tts_nano" elif self._tts_model_type == "moss_tts": @@ -3177,6 +3464,17 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): getattr(sampling_params_list[0], "min_tokens", 0), request.max_new_tokens, ) + elif self._tts_model_type == "ming_tts" and sampling_params_list: + import copy + + from vllm_omni.model_executor.models.ming_tts.config_ming_tts import TEXT_EOS_TOKEN_ID + + sampling_params_list = copy.deepcopy(sampling_params_list) + sampling_params_list[0].stop_token_ids = [int(TEXT_EOS_TOKEN_ID)] + if request.max_new_tokens is not None: + # Ming emits TEXT_EOS after the latent decode budget is exhausted, so + # Stage-0 needs one extra token beyond ming_max_decode_steps. + sampling_params_list[0].max_tokens = int(request.max_new_tokens) + 1 if request.seed is not None and sampling_params_list: if sampling_params_list is self.engine_client.default_sampling_params_list: diff --git a/vllm_omni/model_executor/models/common/ming/__init__.py b/vllm_omni/model_executor/models/common/ming/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/vllm_omni/model_executor/models/common/ming/__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/model_executor/models/common/ming/audio_dsp.py b/vllm_omni/model_executor/models/common/ming/audio_dsp.py new file mode 100644 index 000000000..be8cd489c --- /dev/null +++ b/vllm_omni/model_executor/models/common/ming/audio_dsp.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/audio_tokenizer/istft.py + +import torch +import torch.nn as nn + + +class ISTFT(nn.Module): + """ + Custom implementation of ISTFT since torch.istft doesn't allow custom padding (other than `center=True`) with + windowing. This is because the NOLA (Nonzero Overlap Add) check fails at the edges. + See issue: https://github.com/pytorch/pytorch/issues/62323 + Specifically, in the context of neural vocoding we are interested in "same" padding analogous to CNNs. + The NOLA constraint is met as we trim padded samples anyway. + + Args: + n_fft (int): Size of Fourier transform. + hop_length (int): The distance between neighboring sliding window frames. + win_length (int): The size of window frame and STFT filter. + padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same". + """ + + def __init__(self, n_fft: int, hop_length: int, win_length: int, padding: str = "same"): + super().__init__() + if padding not in ["center", "same"]: + raise ValueError("Padding must be 'center' or 'same'.") + self.padding = padding + self.n_fft = n_fft + self.hop_length = hop_length + self.win_length = win_length + window = torch.hann_window(win_length) + self.register_buffer("window", window) + + self.buffer_len = self.win_length - self.hop_length + + def _buffer_process( + self, + x: torch.Tensor, + buffer: torch.Tensor | None, + pad: int, + last_chunk: bool = False, + streaming: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + if streaming: + if buffer is None: + # first chunk + x = x[:, pad:] + if buffer is not None: + # next chunk + x[:, : self.buffer_len] += buffer + buffer = x[:, -self.buffer_len :] + if not last_chunk: + x = x[:, : -self.buffer_len] + else: + x = x[:, :-pad] + else: + x = x[:, pad:-pad] + + return x, buffer + + def forward( + self, + spec: torch.Tensor, + audio_buffer: torch.Tensor | None = None, + window_buffer: torch.Tensor | None = None, + streaming: bool = False, + last_chunk: bool = False, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]: + """ + Compute the Inverse Short Time Fourier Transform (ISTFT) of a complex spectrogram. + + Args: + spec (Tensor): Input complex spectrogram of shape (B, N, T), where B is the batch size, + N is the number of frequency bins, and T is the number of time frames. + audio_buffer (Tensor): [Streaming Input/State] The audio overlap buffer from the previous chunk. + Shape: (B, win_length - hop_length) + window_buffer (Tensor): [Streaming Input/State] The window overlap buffer from the previous chunk. + streaming: If `True`, the function operates in streaming mode, processing `spec` as a single chunk. + last_chunk: When `streaming=True` and `last_chunk=True`, the function can perform final "flush" operations + + Returns: + Reconstructed signal, plus streaming buffers when `padding="same"`. + """ + if self.padding == "center": + # Fallback to pytorch native implementation + return torch.istft(spec, self.n_fft, self.hop_length, self.win_length, self.window, center=True) + elif self.padding == "same": + pad = (self.win_length - self.hop_length) // 2 + else: + raise ValueError("Padding must be 'center' or 'same'.") + + if spec.dim() != 3: + raise ValueError(f"Expected spec rank-3 [Batch, Freq, Time], got {tuple(spec.shape)}") + _, _, T = spec.shape + + # Inverse FFT + ifft = torch.fft.irfft(spec, self.n_fft, dim=1, norm="backward") + ifft = ifft * self.window[None, :, None] + + # Overlap and Add + output_size = (T - 1) * self.hop_length + self.win_length + y = torch.nn.functional.fold( + ifft, + output_size=(1, output_size), + kernel_size=(1, self.win_length), + stride=(1, self.hop_length), + )[:, 0, 0, :] + + y, audio_buffer = self._buffer_process(y, audio_buffer, pad, last_chunk=last_chunk, streaming=streaming) + + # Window envelope + window_sq = self.window.square().expand(1, T, -1).transpose(1, 2) + window_envelope = ( + torch.nn.functional.fold( + window_sq, + output_size=(1, output_size), + kernel_size=(1, self.win_length), + stride=(1, self.hop_length), + ) + .squeeze(0) + .squeeze(0) + ) + + window_envelope, window_buffer = self._buffer_process( + window_envelope, window_buffer, pad, last_chunk=last_chunk, streaming=streaming + ) + window_envelope = window_envelope.squeeze() + + # Normalize + if not (window_envelope > 1e-11).all(): + raise RuntimeError("ISTFT window envelope underflowed; invalid overlap-add state.") + y = y / window_envelope + + return y, audio_buffer, window_buffer + + +class FourierHead(nn.Module): + """Base class for inverse fourier modules.""" + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Args: + x (Tensor): Input tensor of shape (B, L, H), where B is the batch size, + L is the sequence length, and H denotes the model dimension. + + Returns: + Tensor: Reconstructed time-domain audio signal of shape (B, T), where T is the length of the output signal. + """ + raise NotImplementedError("Subclasses must implement the forward method.") + + +class ISTFTHead(FourierHead): + """ + ISTFT Head module for predicting STFT complex coefficients. + + Args: + dim (int): Hidden dimension of the model. + n_fft (int): Size of Fourier transform. + hop_length (int): The distance between neighboring sliding window frames, which should align with + the resolution of the input features. + padding (str, optional): Type of padding. Options are "center" or "same". Defaults to "same". + """ + + def __init__(self, dim: int, n_fft: int, hop_length: int, padding: str = "same"): + super().__init__() + out_dim = n_fft + 2 + self.out = torch.nn.Linear(dim, out_dim) + self.istft = ISTFT(n_fft=n_fft, hop_length=hop_length, win_length=n_fft, padding=padding) + + def forward( + self, + x: torch.Tensor, + audio_buffer: torch.Tensor | None = None, + window_buffer: torch.Tensor | None = None, + streaming: bool = False, + last_chunk: bool = False, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None, torch.Tensor | None]: + """ + Forward pass of the ISTFTHead module. + + Args: + x (Tensor): Input tensor of shape (B, L, H), where B is the batch size, + L is the sequence length, and H denotes the model dimension. + + Returns: + Audio, predicted spectrogram coefficients, and streaming buffers. + """ + x_pred = self.out(x) + x_pred = x_pred.transpose(1, 2) + mag, p = x_pred.chunk(2, dim=1) + mag = torch.exp(mag) + mag = torch.clip(mag, max=1e2) # safeguard to prevent excessively large magnitudes + # wrapping happens here. These two lines produce real and imaginary value + x = torch.cos(p) + y = torch.sin(p) + # recalculating phase here does not produce anything new + # only costs time + # phase = torch.atan2(y, x) + # S = mag * torch.exp(phase * 1j) + # better directly produce the complex value + S = mag * (x + 1j * y) + audio, audio_buffer, window_buffer = self.istft( + S, audio_buffer=audio_buffer, window_buffer=window_buffer, streaming=streaming, last_chunk=last_chunk + ) + return audio.unsqueeze(1), x_pred, audio_buffer, window_buffer + + +__all__ = ["FourierHead", "ISTFT", "ISTFTHead"] diff --git a/vllm_omni/model_executor/models/ming_flash_omni/audio_vae.py b/vllm_omni/model_executor/models/common/ming/audio_vae.py similarity index 64% rename from vllm_omni/model_executor/models/ming_flash_omni/audio_vae.py rename to vllm_omni/model_executor/models/common/ming/audio_vae.py index 3137a878e..6924ae548 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/audio_vae.py +++ b/vllm_omni/model_executor/models/common/ming/audio_vae.py @@ -1,10 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Copyright 2025 The vLLM-Omni team. -# Copyright (c) Ant Group. All rights reserved. -# Adapted from: -# https://github.com/inclusionAI/Ming/tree/e58533db227031990c5a6864dcf5f08fb53ed0d2/AudioVAE - -from __future__ import annotations +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project import torch import torch.nn as nn @@ -13,6 +8,8 @@ from transformers import PretrainedConfig, PreTrainedModel, Qwen2Config, Qwen2Mo from transformers.utils import is_flash_attn_2_available from vllm.logger import init_logger +from .audio_dsp import ISTFTHead + logger = init_logger(__name__) @@ -21,114 +18,50 @@ class AudioVAEConfig(PretrainedConfig): def __init__( self, - sample_rate: int = 44100, - enc_kwargs: dict | None = None, - dec_kwargs: dict | None = None, - init_method: str = "kaiming", - patch_size: int = 4, + sample_rate=44100, + enc_kwargs=None, + semantic_module_kwargs=None, + dec_kwargs=None, + hifi_gan_disc_kwargs=None, + spec_disc_kwargs=None, + lambda_disc=1.0, + lambda_mel_loss=15, + lambda_adv=1.0, + lambda_feat_match_loss=1.0, + lambda_semantic=5.0, + init_method="kaiming", + patch_size=4, **kwargs, ): self.sample_rate = sample_rate self.enc_kwargs = enc_kwargs or {} + self.semantic_module_kwargs = semantic_module_kwargs self.dec_kwargs = dec_kwargs or {} + self.hifi_gan_disc_kwargs = hifi_gan_disc_kwargs + self.spec_disc_kwargs = spec_disc_kwargs + self.lambda_disc = lambda_disc + self.lambda_mel_loss = lambda_mel_loss + self.lambda_adv = lambda_adv + self.lambda_feat_match_loss = lambda_feat_match_loss + self.lambda_semantic = lambda_semantic self.init_method = init_method self.patch_size = patch_size super().__init__(**kwargs) -class ISTFT(nn.Module): - def __init__(self, n_fft: int, hop_length: int, win_length: int, padding: str = "same"): - super().__init__() - if padding not in ["center", "same"]: - raise ValueError("Padding must be 'center' or 'same'.") - self.padding = padding - self.n_fft = n_fft - self.hop_length = hop_length - self.win_length = win_length - window = torch.hann_window(win_length) - self.register_buffer("window", window) - self.buffer_len = self.win_length - self.hop_length - - def _buffer_process(self, x, buffer, pad, last_chunk=False, streaming=False): - if streaming: - if buffer is None: - x = x[:, pad:] - if buffer is not None: - x[:, : self.buffer_len] += buffer - buffer = x[:, -self.buffer_len :] - if not last_chunk: - x = x[:, : -self.buffer_len] - else: - x = x[:, :-pad] - else: - x = x[:, pad:-pad] - return x, buffer - - def forward(self, spec, audio_buffer=None, window_buffer=None, streaming=False, last_chunk=False): - if self.padding == "center": - return torch.istft(spec, self.n_fft, self.hop_length, self.win_length, self.window, center=True) - elif self.padding == "same": - pad = (self.win_length - self.hop_length) // 2 - else: - raise ValueError("Padding must be 'center' or 'same'.") - - B, N, T = spec.shape - ifft = torch.fft.irfft(spec, self.n_fft, dim=1, norm="backward") - ifft = ifft * self.window[None, :, None] - - output_size = (T - 1) * self.hop_length + self.win_length - y = torch.nn.functional.fold( - ifft, - output_size=(1, output_size), - kernel_size=(1, self.win_length), - stride=(1, self.hop_length), - )[:, 0, 0, :] - - y, audio_buffer = self._buffer_process(y, audio_buffer, pad, last_chunk=last_chunk, streaming=streaming) - - window_sq = self.window.square().expand(1, T, -1).transpose(1, 2) - window_envelope = ( - torch.nn.functional.fold( - window_sq, - output_size=(1, output_size), - kernel_size=(1, self.win_length), - stride=(1, self.hop_length), - ) - .squeeze(0) - .squeeze(0) - ) - - window_envelope, window_buffer = self._buffer_process( - window_envelope, window_buffer, pad, last_chunk=last_chunk, streaming=streaming - ) - window_envelope = window_envelope.squeeze() - - assert (window_envelope > 1e-11).all() - y = y / window_envelope - - return y, audio_buffer, window_buffer - - -class ISTFTHead(nn.Module): - def __init__(self, dim: int, n_fft: int, hop_length: int, padding: str = "same"): - super().__init__() - out_dim = n_fft + 2 - self.out = nn.Linear(dim, out_dim) - self.istft = ISTFT(n_fft=n_fft, hop_length=hop_length, win_length=n_fft, padding=padding) - - def forward(self, x, audio_buffer=None, window_buffer=None, streaming=False, last_chunk=False): - x_pred = self.out(x) - x_pred = x_pred.transpose(1, 2) - mag, p = x_pred.chunk(2, dim=1) - mag = torch.exp(mag) - mag = torch.clip(mag, max=1e2) - x = torch.cos(p) - y = torch.sin(p) - S = mag * (x + 1j * y) - audio, audio_buffer, window_buffer = self.istft( - S, audio_buffer=audio_buffer, window_buffer=window_buffer, streaming=streaming, last_chunk=last_chunk - ) - return audio.unsqueeze(1), x_pred, audio_buffer, window_buffer +def _qwen2_config(backbone): + config = Qwen2Config.from_dict(config_dict=backbone) + if (getattr(config, "_attn_implementation", None) or getattr(config, "attn_implementation", None)) not in ( + None, + "flash_attention_2", + ): + return config + if is_flash_attn_2_available(): + config._attn_implementation_autoset = True + config._attn_implementation = "flash_attention_2" + else: + config._attn_implementation = "sdpa" + return config class StreamingLinearUpsample(nn.Module): @@ -138,6 +71,8 @@ class StreamingLinearUpsample(nn.Module): self.upsampler = nn.Upsample(scale_factor=scale_factor, mode="linear", align_corners=False) def forward(self, x, state=None, is_last=False): + if x is None and is_last and (state is None or state.get("prev_chunk") is None): + raise ValueError("Received end-of-stream without any latent chunk to upsample.") if state is None: state = {"prev_chunk": None, "history_last": None, "is_first": True} @@ -188,16 +123,72 @@ class StreamingLinearUpsample(nn.Module): return final_out, state +class Encoder(nn.Module): + def __init__(self, encoder_args, input_dim=320, hop_size=320, latent_dim=64, patch_size=-1): + super().__init__() + config = _qwen2_config(encoder_args) + logger.info("AudioVAE Encoder: using attn_implementation=%r", config._attn_implementation) + self.encoder = Qwen2Model(config) + self.input_dim = input_dim + self.hop_size = hop_size + self.latent_dim = latent_dim + self.fc1 = nn.Linear(input_dim, config.hidden_size, bias=False) + self.fc2 = nn.Linear(config.hidden_size, config.hidden_size) + self.fc3 = nn.Linear(config.hidden_size, latent_dim * 2) + self.norm = nn.LayerNorm(config.hidden_size) + self.patch_size = patch_size + if patch_size != -1: + aggregator_config = _qwen2_config({**encoder_args, "num_hidden_layers": 4}) + self.aggregator = Qwen2Model(aggregator_config) + self.cls_embed = nn.Parameter(torch.rand(1, 1, config.hidden_size)) + self.cls_embed.data.normal_(0, 0.02) + + def get_frames(self, x): + num_frames_total = (x.size(-1) + self.hop_size - 1) // self.hop_size + expected_len = (num_frames_total - 1) * self.hop_size + self.input_dim + padding_needed = expected_len - x.size(-1) + waveform = F.pad(x, (0, padding_needed), value=0.0) + frames = waveform.unfold(dimension=-1, size=self.input_dim, step=self.hop_size) + return frames + + def pad_patch_insert_cls(self, x): + bsz, _, dim = x.size() + num_frame = x.size(1) + r = num_frame % self.patch_size + pad_num = self.patch_size - r if r else 0 + x = F.pad(x, (0, 0, 0, pad_num), value=0.0) + # [Batch, Time, Dimension] -> [Batch*PatchGroups, Patch, Dimension]. + x = x.reshape(-1, self.patch_size, dim) + x = torch.cat((x, self.cls_embed.expand(x.size(0), -1, -1)), dim=1) + # [Batch*PatchGroups, Patch+1, Dimension] -> [Batch, Time, Dimension]. + x = x.reshape(bsz, -1, dim) + return x + + def forward(self, waveform): + x = self.get_frames(waveform) + x = self.fc1(x) + x = self.fc2(x) + x = self.encoder(inputs_embeds=x) + x = x.last_hidden_state + + if self.patch_size != -1: + x = self.pad_patch_insert_cls(x) + x = self.aggregator(inputs_embeds=x) + x = x.last_hidden_state + bsz, _, dim = x.size() + # [Batch, Time, Dimension] -> [Batch*PatchGroups, Patch+1, Dimension]. + x = x.reshape(-1, self.patch_size + 1, dim) + # [Batch*PatchGroups, 1, Dimension] -> [Batch, PatchGroups, Dimension]. + x = x[:, -1:, :].reshape(bsz, -1, dim) + + x = self.fc3(x) + return x, waveform.unsqueeze(1) + + class Decoder(nn.Module): def __init__(self, decoder_args, output_dim=320, latent_dim=64, patch_size=-1): super().__init__() - config = Qwen2Config.from_dict(config_dict=decoder_args) - if is_flash_attn_2_available(): - config._attn_implementation_autoset = True - config._attn_implementation = "flash_attention_2" - else: - config._attn_implementation = "sdpa" - + config = _qwen2_config(decoder_args) logger.info("AudioVAE Decoder: using attn_implementation=%r", config._attn_implementation) self.decoder = Qwen2Model(config) self.output_dim = output_dim @@ -211,7 +202,21 @@ class Decoder(nn.Module): if self.patch_size != -1: self.upsampling = StreamingLinearUpsample(scale_factor=patch_size) + def forward(self, x): + x = self.fc1(x) + + if self.patch_size != -1: + # [Batch, Time, Dimension] -> [Batch, Dimension, Time] -> [Batch, Time, Dimension]. + x = self.upsampling.upsampler(x.transpose(1, 2)).transpose(1, 2) + + x = self.decoder(inputs_embeds=x) + x = x.last_hidden_state + x, _ = self.head(x) + return x, None + def low_level_reconstruct(self, x, past_key_values=None, use_cache=False, stream_state=None, last_chunk=False): + if stream_state is None: + stream_state = (None, None, None) upsample_state, audio_buffer, window_buffer = stream_state bsz, device, dtype = x.size(0), x.device, x.dtype x = self.fc1(x) @@ -222,6 +227,7 @@ class Decoder(nn.Module): stream_state = (upsample_state, audio_buffer, window_buffer) return torch.empty(bsz, 1, 0, device=device, dtype=dtype), stream_state, past_key_values else: + # [Batch, Time, Dimension] -> [Batch, Dimension, Time] -> [Batch, Time, Dimension]. x = self.upsampling.upsampler(x.transpose(1, 2)).transpose(1, 2) hidden_states_list = [] @@ -239,7 +245,6 @@ class Decoder(nn.Module): past_len = 0 curr_len = x.shape[1] - if past_len < target_len and (past_len + curr_len) >= sw_size: fill_len = target_len - past_len x_fill = x[:, :fill_len, :] @@ -269,86 +274,39 @@ class Decoder(nn.Module): return x_out, stream_state, past_key_values -class Encoder(nn.Module): - def __init__(self, encoder_args, input_dim=320, hop_size=320, latent_dim=64, patch_size=-1): - super().__init__() - config = Qwen2Config.from_dict(config_dict=encoder_args) - if is_flash_attn_2_available(): - config._attn_implementation_autoset = True - config._attn_implementation = "flash_attention_2" - else: - config._attn_implementation = "sdpa" - - logger.info("AudioVAE Encoder: using attn_implementation=%r", config._attn_implementation) - self.encoder = Qwen2Model(config) - self.input_dim = input_dim - self.hop_size = hop_size - self.latent_dim = latent_dim - self.fc1 = nn.Linear(input_dim, config.hidden_size, bias=False) - self.fc2 = nn.Linear(config.hidden_size, config.hidden_size) - self.fc3 = nn.Linear(config.hidden_size, latent_dim * 2) - self.norm = nn.LayerNorm(config.hidden_size) - self.patch_size = patch_size - if patch_size != -1: - config.num_hidden_layers = 4 - self.aggregator = Qwen2Model(config) - self.cls_embed = nn.Parameter(torch.rand(1, 1, config.hidden_size)) - self.cls_embed.data.normal_(0, 0.02) - - def get_frames(self, x): - num_frames_total = (x.size(-1) + self.hop_size - 1) // self.hop_size - expected_len = (num_frames_total - 1) * self.hop_size + self.input_dim - padding_needed = expected_len - x.size(-1) - waveform = F.pad(x, (0, padding_needed), value=0.0) - frames = waveform.unfold(dimension=-1, size=self.input_dim, step=self.hop_size) - return frames - - def pad_patch_insert_cls(self, x): - bsz, _, dim = x.size() - num_frame = x.size(1) - r = num_frame % self.patch_size - pad_num = self.patch_size - r if r else 0 - x = F.pad(x, (0, 0, 0, pad_num), value=0.0) - x = x.reshape(-1, self.patch_size, dim) - x = torch.cat((x, self.cls_embed.expand(x.size(0), -1, -1)), dim=1) - x = x.reshape(bsz, -1, dim) - return x - - def forward(self, waveform): - x = self.get_frames(waveform) - x = self.fc1(x) - x = self.fc2(x) - x = self.encoder(inputs_embeds=x) - x = x.last_hidden_state - - if self.patch_size != -1: - x = self.pad_patch_insert_cls(x) - x = self.aggregator(inputs_embeds=x) - x = x.last_hidden_state - bsz, _, dim = x.size() - x = x.reshape(-1, self.patch_size + 1, dim) - x = x[:, -1:, :].reshape(bsz, -1, dim) - - x = self.fc3(x) - return x, waveform.unsqueeze(1) - - class AudioVAE(PreTrainedModel): config_class = AudioVAEConfig - def __init__(self, config: AudioVAEConfig): + def __init__(self, config): super().__init__(config) + self.config = config + enc_kwargs = config.enc_kwargs + dec_kwargs = config.dec_kwargs + for key in ("backbone", "input_dim", "latent_dim"): + if key not in enc_kwargs: + raise ValueError(f"AudioVAE.enc_kwargs missing required key: {key}") + for key in ("backbone", "output_dim", "latent_dim"): + if key not in dec_kwargs: + raise ValueError(f"AudioVAE.dec_kwargs missing required key: {key}") + + hop_size = enc_kwargs.get("hop_size", enc_kwargs["input_dim"]) + if enc_kwargs["input_dim"] != hop_size: + raise ValueError(f"AudioVAE encoder input_dim ({enc_kwargs['input_dim']}) != hop_size ({hop_size}).") + if hop_size != dec_kwargs["output_dim"]: + raise ValueError( + f"AudioVAE encoder hop_size ({hop_size}) != decoder output_dim ({dec_kwargs['output_dim']})." + ) self.encoder = Encoder( - encoder_args=config.enc_kwargs["backbone"], - input_dim=config.enc_kwargs["input_dim"], - hop_size=config.enc_kwargs.get("hop_size", 320), - latent_dim=config.enc_kwargs["latent_dim"], + encoder_args=enc_kwargs["backbone"], + input_dim=enc_kwargs["input_dim"], + hop_size=hop_size, + latent_dim=enc_kwargs["latent_dim"], patch_size=config.patch_size, ) self.decoder = Decoder( - decoder_args=config.dec_kwargs["backbone"], - output_dim=config.dec_kwargs["output_dim"], - latent_dim=config.dec_kwargs["latent_dim"], + decoder_args=dec_kwargs["backbone"], + output_dim=dec_kwargs["output_dim"], + latent_dim=dec_kwargs["latent_dim"], patch_size=config.patch_size, ) self.post_init() @@ -367,21 +325,50 @@ class AudioVAE(PreTrainedModel): if module.padding_idx is not None: module.weight.data[module.padding_idx].zero_() + @torch.inference_mode() def encode_latent(self, waveform, waveform_length): - from diffusers.models.autoencoders.autoencoder_oobleck import OobleckDiagonalGaussianDistribution + if waveform.ndim != 2: + raise ValueError(f"Expected waveform rank-2 [Batch, Time], got {tuple(waveform.shape)}") + if waveform_length.ndim != 1: + raise ValueError(f"Expected waveform_length rank-1 [Batch], got {tuple(waveform_length.shape)}") + if waveform.shape[0] != waveform_length.shape[0]: + raise ValueError( + "Batch mismatch: " + f"waveform batch={waveform.shape[0]} vs " + f"waveform_length batch={waveform_length.shape[0]}" + ) + if torch.any(waveform_length <= 0): + raise ValueError("waveform_length must be strictly positive.") frame_num = torch.ceil(waveform_length / self.config.enc_kwargs["input_dim"]).to(torch.int32) if self.config.patch_size != -1: frame_num = torch.ceil(frame_num / self.config.patch_size) - h, y = self.encoder(waveform) + h, _ = self.encoder(waveform) h = h.transpose(1, 2) - posterior = OobleckDiagonalGaussianDistribution(h) - latent = posterior.sample() + mean, logvar = torch.chunk(h, 2, dim=1) + logvar = torch.clamp(logvar, -30.0, 20.0) + std = torch.exp(0.5 * logvar) + latent = mean + std * torch.randn_like(mean) latent = latent.transpose(1, 2) return latent, frame_num + @torch.inference_mode() def decode(self, latent, past_key_values=None, use_cache=False, stream_state=(None, None, None), last_chunk=False): + if latent.dim() != 3: + raise ValueError(f"Expected latent rank-3 [B,T,D], got shape={tuple(latent.shape)}") + if latent.shape[0] <= 0: + raise ValueError("latent batch size must be positive.") + + target_dtype = next(self.decoder.parameters()).dtype + target_device = next(self.decoder.parameters()).device + if latent.dtype != target_dtype or latent.device != target_device: + latent = latent.to(device=target_device, dtype=target_dtype) + + expected_latent_dim = self.config.dec_kwargs["latent_dim"] + if latent.shape[-1] != expected_latent_dim: + raise ValueError(f"Latent dim mismatch in decode(): got {latent.shape[-1]}, expected {expected_latent_dim}") + waveform, stream_state, past_key_values = self.decoder.low_level_reconstruct( latent, past_key_values=past_key_values, diff --git a/vllm_omni/model_executor/models/common/ming/dit.py b/vllm_omni/model_executor/models/common/ming/dit.py new file mode 100644 index 000000000..3b4ab1122 --- /dev/null +++ b/vllm_omni/model_executor/models/common/ming/dit.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import torch +import torch.nn.functional as F +from torch import nn +from x_transformers.x_transformers import apply_rotary_pos_emb + + +class RMSNorm(nn.Module): + def __init__(self, dim, eps=1e-6): + super().__init__() + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + if self.weight.dtype in [torch.float16, torch.bfloat16]: + x = x.to(self.weight.dtype) + x = F.rms_norm(x, normalized_shape=(x.shape[-1],), weight=self.weight, eps=self.eps) + return x + + +class FeedForward(nn.Module): + def __init__(self, dim, dim_out=None, mult=4, dropout=0.0, approximate="none"): + super().__init__() + inner_dim = int(dim * mult) + dim_out = dim_out if dim_out is not None else dim + + activation = nn.GELU(approximate=approximate) + project_in = nn.Sequential(nn.Linear(dim, inner_dim), activation) + self.ff = nn.Sequential(project_in, nn.Dropout(dropout), nn.Linear(inner_dim, dim_out)) + + def forward(self, x): + return self.ff(x) + + +class Attention(nn.Module): + def __init__( + self, + dim, + heads=8, + dim_head=64, + dropout=0.0, + qk_norm=None, + pe_attn_head=None, + attn_mask_enabled=True, + ): + super().__init__() + self.dim = dim + self.heads = heads + self.inner_dim = dim_head * heads + self.dropout = dropout + self.to_q = nn.Linear(dim, self.inner_dim) + self.to_k = nn.Linear(dim, self.inner_dim) + self.to_v = nn.Linear(dim, self.inner_dim) + if qk_norm is None: + self.q_norm = None + self.k_norm = None + elif qk_norm == "rms_norm": + self.q_norm = RMSNorm(dim_head) + self.k_norm = RMSNorm(dim_head) + else: + raise ValueError(f"Unimplemented qk_norm: {qk_norm}") + + self.to_out = nn.ModuleList([]) + self.to_out.append(nn.Linear(self.inner_dim, dim)) + self.to_out.append(nn.Dropout(dropout)) + self.pe_attn_head = pe_attn_head + self.attn_mask_enabled = attn_mask_enabled + + def forward(self, x, mask=None, rope=None): + batch_size = x.shape[0] + query = self.to_q(x) + key = self.to_k(x) + value = self.to_v(x) + + inner_dim = key.shape[-1] + head_dim = inner_dim // self.heads + query = query.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) + key = key.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) + value = value.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) + + if self.q_norm is not None: + query = self.q_norm(query) + if self.k_norm is not None: + key = self.k_norm(key) + + if rope is not None: + freqs, xpos_scale = rope + q_xpos_scale, k_xpos_scale = (xpos_scale, xpos_scale**-1.0) if xpos_scale is not None else (1.0, 1.0) + if self.pe_attn_head is not None: + on = self.pe_attn_head + query[:, :on, :, :] = apply_rotary_pos_emb(query[:, :on, :, :], freqs, q_xpos_scale) + key[:, :on, :, :] = apply_rotary_pos_emb(key[:, :on, :, :], freqs, k_xpos_scale) + else: + query = apply_rotary_pos_emb(query, freqs, q_xpos_scale) + key = apply_rotary_pos_emb(key, freqs, k_xpos_scale) + + if self.attn_mask_enabled and mask is not None: + valid_sample_indices = mask.any(dim=1) + final_output = torch.zeros_like(query).to(query.device) + attn_mask = mask[valid_sample_indices] + query = query[valid_sample_indices] + key = key[valid_sample_indices] + value = value[valid_sample_indices] + attn_mask = attn_mask.unsqueeze(1).unsqueeze(1) + attn_mask = attn_mask.expand(valid_sample_indices.sum().item(), self.heads, query.shape[-2], key.shape[-2]) + else: + attn_mask = None + + x = F.scaled_dot_product_attention(query, key, value, attn_mask=attn_mask, dropout_p=0.0, is_causal=False) + if self.attn_mask_enabled and mask is not None: + final_output[valid_sample_indices] = x + x = final_output + + x = x.transpose(1, 2).reshape(batch_size, -1, self.heads * head_dim) + x = x.to(query.dtype) + x = self.to_out[0](x) + x = self.to_out[1](x) + + if mask is not None: + mask = mask.unsqueeze(-1) + x = x.masked_fill(~mask, 0.0) + + return x + + +class DiTBlock(nn.Module): + def __init__( + self, + hidden_size, + num_heads, + mlp_ratio=4.0, + dropout=0.1, + qk_norm=None, + pe_attn_head=None, + attn_mask_enabled=True, + **kwargs, + ): + super().__init__() + self.norm1 = RMSNorm(hidden_size) + self.attn = Attention( + dim=hidden_size, + heads=num_heads, + dim_head=hidden_size // num_heads, + dropout=dropout, + qk_norm=qk_norm, + pe_attn_head=pe_attn_head, + attn_mask_enabled=attn_mask_enabled, + ) + self.norm2 = RMSNorm(hidden_size) + self.mlp = FeedForward(dim=hidden_size, mult=mlp_ratio, dropout=dropout, approximate="tanh") + + def forward(self, x, mask, rope): + x = x + self.attn(self.norm1(x), mask=mask, rope=rope) + x = x + self.mlp(self.norm2(x)) + return x + + +class FinalLayer(nn.Module): + def __init__(self, hidden_size, out_channels): + super().__init__() + self.norm_final = RMSNorm(hidden_size) + self.linear = nn.Linear(hidden_size, out_channels, bias=True) + + def forward(self, x): + x = self.norm_final(x) + x = self.linear(x) + return x + + +class CondEmbedder(nn.Module): + def __init__(self, input_feature_size, hidden_size): + super().__init__() + self.cond_embedder = nn.Linear(input_feature_size, hidden_size) + + def forward(self, llm_cond): + return self.cond_embedder(llm_cond) + + +def get_epss_timesteps(n, device, dtype): + dt = 1 / 32 + predefined_timesteps = { + 5: [0, 2, 4, 8, 16, 32], + 6: [0, 2, 4, 6, 8, 16, 32], + 7: [0, 2, 4, 6, 8, 16, 24, 32], + 10: [0, 2, 4, 6, 8, 12, 16, 20, 24, 28, 32], + 12: [0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32], + 16: [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24, 28, 32], + } + t = predefined_timesteps.get(n, []) + if not t: + return torch.linspace(0, 1, n + 1, device=device, dtype=dtype) + return dt * torch.tensor(t, device=device, dtype=dtype) diff --git a/vllm_omni/model_executor/models/common/ming/fm.py b/vllm_omni/model_executor/models/common/ming/fm.py new file mode 100644 index 000000000..d2cbc6211 --- /dev/null +++ b/vllm_omni/model_executor/models/common/ming/fm.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import torch + +from .dit import get_epss_timesteps + + +def apply_sway_sampling(t, sway_sampling_coef): + if sway_sampling_coef is None: + return t + return t + sway_sampling_coef * (torch.cos(torch.pi / 2 * t) - 1 + t) + + +def build_timesteps(steps, device, dtype, use_epss=True, sway_sampling_coef=-1.0): + if steps <= 0: + raise ValueError(f"steps must be positive, got {steps}") + if use_epss: + t = get_epss_timesteps(steps, device=device, dtype=dtype) + else: + t = torch.linspace(0, 1, steps + 1, device=device, dtype=dtype) + return apply_sway_sampling(t, sway_sampling_coef) + + +class Solver: + def __init__(self, func, y0, sigma=0.25, temperature=1.5) -> None: + self.func = func + self.y0 = y0 + self.sigma = sigma + self.temperature = temperature + + def integrate(self, t): + solution = torch.empty(len(t), *self.y0.shape, dtype=self.y0.dtype, device=self.y0.device) + solution[0] = self.y0 + + j = 1 + y0 = self.y0 + for t0, t1 in zip(t[:-1], t[1:]): + dt = t1 - t0 + f0 = self.func(t0, y0) + dy = dt * f0 + y1 = y0 + dy + + while j < len(t) and t1 >= t[j]: + solution[j] = self._linear_interp(t0, t1, y0, y1, t[j]) + j += 1 + + noise = torch.randn_like(y0) + shift = self.sigma * (self.temperature**0.5) * (abs(dt) ** 0.5) * noise + y0 = y1 + shift + + return solution + + def _linear_interp(self, t0, t1, y0, y1, t): + if t == t0: + return y0 + if t == t1: + return y1 + slope = (t - t0) / (t1 - t0) + return y0 + slope * (y1 - y0) + + +def integrate_cfm_steps(fn, y0, t, sde_args, sde_rnd, steps): + for step in range(steps): + dt = t[step + 1] - t[step] + y0 = y0 + fn(t[step], y0) * dt + y0 = y0 + sde_args[1] * (sde_args[2] ** 0.5) * (dt.abs() ** 0.5) * sde_rnd[step] + return y0 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 08ed9e854..e7b305b1e 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 @@ -26,10 +26,10 @@ from vllm.sequence import IntermediateTensors from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific +from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAE, AudioVAEConfig from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.transformers_utils.configs.ming_flash_omni import MingFlashOmniTalkerConfig -from .audio_vae import AudioVAE, AudioVAEConfig from .prompt_utils import DEFAULT_PROMPT as MING_DEFAULT_PROMPT from .talker_module import CFM, Aggregator, DiT, MingAudioGenerator, build_tts_input from .text_processing import segment_and_normalize 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 e0a49935d..8c527b8d9 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 @@ -697,6 +697,9 @@ class MingFlashOmniThinkerForConditionalGeneration( f"image_gen_scales={list(self.query_tokens_dict.keys())}" ) + def get_language_model(self): + return self.language_model + def _load_image_gen_query_tokens(self, model_path: str) -> None: """Load learnable query tokens used for image generation. diff --git a/vllm_omni/model_executor/models/ming_flash_omni/prompt_utils.py b/vllm_omni/model_executor/models/ming_flash_omni/prompt_utils.py index 53c130b90..18deb4600 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/prompt_utils.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/prompt_utils.py @@ -110,12 +110,18 @@ BASE_CAPTION_TEMPLATE: dict[str, Any] = { } -def create_instruction(user_input: dict[str, Any]) -> str: +def create_instruction(user_input: Any) -> str | None: """Return a JSON caption string for ``audio_sequence[0]``. Only keys already present on the base template are merged in; unknown keys are silently ignored to keep the output schema stable. """ + if user_input is None: + return None + if isinstance(user_input, str): + return user_input + if not isinstance(user_input, dict): + raise ValueError(f"Ming instruction must be str, dict, or None; got {type(user_input).__name__}") caption = copy.deepcopy(BASE_CAPTION_TEMPLATE) item = caption["audio_sequence"][0] for key, value in user_input.items(): diff --git a/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py b/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py index 6b2c6b2f2..cfb1090c2 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/talker_module.py @@ -27,223 +27,19 @@ from threading import Lock import torch import torch.nn as nn -import torch.nn.functional as F from transformers import PreTrainedTokenizerBase, Qwen2Config, Qwen2Model, StaticCache from vllm.logger import init_logger from vllm.platforms import current_platform -from x_transformers.x_transformers import RotaryEmbedding, apply_rotary_pos_emb +from x_transformers.x_transformers import RotaryEmbedding from vllm_omni.model_executor.layers.timestep_embedding import DiTTimestepEmbedding - -from .audio_vae import AudioVAE +from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAE +from vllm_omni.model_executor.models.common.ming.dit import CondEmbedder, DiTBlock, FinalLayer, get_epss_timesteps +from vllm_omni.model_executor.models.common.ming.fm import apply_sway_sampling, integrate_cfm_steps logger = init_logger(__name__) -######################################################################## -# DiT Modules -# Ported from: -# https://github.com/inclusionAI/Ming/blob/e58533db227031990c5a6864dcf5f08fb53ed0d2/talker_module/modules.py -# Ported from: -# https://github.com/inclusionAI/Ming/blob/e58533db227031990c5a6864dcf5f08fb53ed0d2/talker_module/dit.py -######################################################################## - - -class RMSNorm(nn.Module): - def __init__(self, dim: int, eps: float = 1e-6): - super().__init__() - self.eps = eps - self.weight = nn.Parameter(torch.ones(dim)) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - if self.weight.dtype in [torch.float16, torch.bfloat16]: - x = x.to(self.weight.dtype) - x = F.rms_norm(x, normalized_shape=(x.shape[-1],), weight=self.weight, eps=self.eps) - return x - - -class FeedForward(nn.Module): - def __init__( - self, dim: int, dim_out: int | None = None, mult: float = 4, dropout: float = 0.0, approximate: str = "none" - ): - super().__init__() - inner_dim = int(dim * mult) - dim_out = dim_out if dim_out is not None else dim - - activation = nn.GELU(approximate=approximate) - project_in = nn.Sequential(nn.Linear(dim, inner_dim), activation) - self.ff = nn.Sequential(project_in, nn.Dropout(dropout), nn.Linear(inner_dim, dim_out)) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.ff(x) - - -class Attention(nn.Module): - def __init__( - self, - dim: int, - heads: int = 8, - dim_head: int = 64, - dropout: float = 0.0, - qk_norm: str | None = None, - pe_attn_head: int | None = None, - attn_mask_enabled: bool = True, - ): - super().__init__() - self.dim = dim - self.heads = heads - self.inner_dim = dim_head * heads - self.dropout = dropout - - self.to_q = nn.Linear(dim, self.inner_dim) - self.to_k = nn.Linear(dim, self.inner_dim) - self.to_v = nn.Linear(dim, self.inner_dim) - if qk_norm is None: - self.q_norm = None - self.k_norm = None - elif qk_norm == "rms_norm": - self.q_norm = RMSNorm(dim_head) - self.k_norm = RMSNorm(dim_head) - else: - raise ValueError(f"Unimplemented qk_norm: {qk_norm}") - - self.to_out = nn.ModuleList([]) - self.to_out.append(nn.Linear(self.inner_dim, dim)) - self.to_out.append(nn.Dropout(dropout)) - - self.pe_attn_head = pe_attn_head - self.attn_mask_enabled = attn_mask_enabled - - def forward( - self, - x: torch.Tensor, - mask: torch.Tensor | None = None, - rope: tuple[torch.Tensor, torch.Tensor | None] | None = None, - ) -> torch.Tensor: - batch_size = x.shape[0] - - query = self.to_q(x) - key = self.to_k(x) - value = self.to_v(x) - - inner_dim = key.shape[-1] - head_dim = inner_dim // self.heads - query = query.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) - key = key.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) - value = value.view(batch_size, -1, self.heads, head_dim).transpose(1, 2) - - if self.q_norm is not None: - query = self.q_norm(query) - if self.k_norm is not None: - key = self.k_norm(key) - - if rope is not None: - freqs, xpos_scale = rope - q_xpos_scale, k_xpos_scale = (xpos_scale, xpos_scale**-1.0) if xpos_scale is not None else (1.0, 1.0) - - if self.pe_attn_head is not None: - on = self.pe_attn_head - query[:, :on, :, :] = apply_rotary_pos_emb(query[:, :on, :, :], freqs, q_xpos_scale) - key[:, :on, :, :] = apply_rotary_pos_emb(key[:, :on, :, :], freqs, k_xpos_scale) - else: - query = apply_rotary_pos_emb(query, freqs, q_xpos_scale) - key = apply_rotary_pos_emb(key, freqs, k_xpos_scale) - - if self.attn_mask_enabled and mask is not None: - valid_sample_indices = mask.any(dim=1) - final_output = torch.zeros_like(query).to(query.device) - - attn_mask = mask[valid_sample_indices] - query = query[valid_sample_indices] - key = key[valid_sample_indices] - value = value[valid_sample_indices] - attn_mask = attn_mask.unsqueeze(1).unsqueeze(1) - attn_mask = attn_mask.expand(valid_sample_indices.sum().item(), self.heads, query.shape[-2], key.shape[-2]) - else: - attn_mask = None - - x = F.scaled_dot_product_attention(query, key, value, attn_mask=attn_mask, dropout_p=0.0, is_causal=False) - if self.attn_mask_enabled and mask is not None: - final_output[valid_sample_indices] = x - x = final_output - - x = x.transpose(1, 2).reshape(batch_size, -1, self.heads * head_dim) - x = x.to(query.dtype) - - x = self.to_out[0](x) - x = self.to_out[1](x) - - if mask is not None: - mask = mask.unsqueeze(-1) - x = x.masked_fill(~mask, 0.0) - - return x - - -class DiTBlock(nn.Module): - """A DiT block with pre-norm and residual connections.""" - - def __init__( - self, - hidden_size: int, - num_heads: int, - mlp_ratio: float = 4.0, - dropout: float = 0.1, - qk_norm: str | None = None, - pe_attn_head: int | None = None, - attn_mask_enabled: bool = True, - **kwargs, - ): - super().__init__() - self.norm1 = RMSNorm(hidden_size) - self.attn = Attention( - dim=hidden_size, - heads=num_heads, - dim_head=hidden_size // num_heads, - dropout=dropout, - qk_norm=qk_norm, - pe_attn_head=pe_attn_head, - attn_mask_enabled=attn_mask_enabled, - ) - self.norm2 = RMSNorm(hidden_size) - self.mlp = FeedForward(dim=hidden_size, mult=mlp_ratio, dropout=dropout, approximate="tanh") - - def forward( - self, - x: torch.Tensor, - mask: torch.Tensor | None, - rope: tuple[torch.Tensor, torch.Tensor | None] | None, - ) -> torch.Tensor: - x = x + self.attn(self.norm1(x), mask=mask, rope=rope) - x = x + self.mlp(self.norm2(x)) - return x - - -class FinalLayer(nn.Module): - """The final layer of DiT.""" - - def __init__(self, hidden_size: int, out_channels: int): - super().__init__() - self.norm_final = RMSNorm(hidden_size) - self.linear = nn.Linear(hidden_size, out_channels, bias=True) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = self.norm_final(x) - x = self.linear(x) - return x - - -class CondEmbedder(nn.Module): - """Embeds LLM hidden states with optional CFG dropout.""" - - def __init__(self, input_feature_size: int, hidden_size: int): - super().__init__() - self.cond_embedder = nn.Linear(input_feature_size, hidden_size) - - def forward(self, llm_cond: torch.Tensor) -> torch.Tensor: - return self.cond_embedder(llm_cond) - - class DiT(nn.Module): """Diffusion model with a Transformer backbone for audio latent generation.""" @@ -324,29 +120,6 @@ class DiT(nn.Module): return model_out[:, -x.shape[1] :, :] -######################################################################################### -# CFM -# Ported from: -# https://github.com/inclusionAI/Ming/blob/e58533db227031990c5a6864dcf5f08fb53ed0d2/talker_module/cfm.py -######################################################################################### - - -def get_epss_timesteps(n, device, dtype): - dt = 1 / 32 - predefined_timesteps = { - 5: [0, 2, 4, 8, 16, 32], - 6: [0, 2, 4, 6, 8, 16, 32], - 7: [0, 2, 4, 6, 8, 16, 24, 32], - 10: [0, 2, 4, 6, 8, 12, 16, 20, 24, 28, 32], - 12: [0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32], - 16: [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24, 28, 32], - } - t = predefined_timesteps.get(n, []) - if not t: - return torch.linspace(0, 1, n + 1, device=device, dtype=dtype) - return dt * torch.tensor(t, device=device, dtype=dtype) - - class CFM(nn.Module): """Conditional Flow Matching module for audio latent generation.""" @@ -391,15 +164,8 @@ class CFM(nn.Module): pred, null_pred = torch.chunk(pred_cfg, 2, dim=0) return pred + (pred - null_pred) * sde_args[0] - if self.sway_sampling_coef is not None: - t = t + self.sway_sampling_coef * (torch.cos(torch.pi / 2 * t) - 1 + t) - - for step in range(self.steps): - dt = t[step + 1] - t[step] - y0 = y0 + fn(t[step], y0) * dt - y0 = y0 + sde_args[1] * (sde_args[2] ** 0.5) * (dt.abs() ** 0.5) * sde_rnd[step] - - return y0 + t = apply_sway_sampling(t, self.sway_sampling_coef) + return integrate_cfm_steps(fn, y0, t, sde_args, sde_rnd, self.steps) class CFMGraphExecutor: diff --git a/vllm_omni/model_executor/models/ming_flash_omni/voice_presets.py b/vllm_omni/model_executor/models/ming_flash_omni/voice_presets.py index 5f54687c0..0a096df86 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/voice_presets.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/voice_presets.py @@ -22,7 +22,8 @@ from .spk_embedding import SpkembExtractor from .talker_module import resample if TYPE_CHECKING: - from .audio_vae import AudioVAE + from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAE + from .talker_module import Aggregator logger = init_logger(__name__) diff --git a/vllm_omni/model_executor/models/ming_tts/__init__.py b/vllm_omni/model_executor/models/ming_tts/__init__.py new file mode 100644 index 000000000..35afa2ce5 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/__init__.py @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from .config_ming_tts import MingDenseConfig +from .ming_tts import MingTTSForConditionalGeneration +from .ming_tts_audio_vae import MingAudioVAEModel +from .ming_tts_llm import MingLLMModel + +__all__ = [ + "MingDenseConfig", + "MingTTSForConditionalGeneration", + "MingLLMModel", + "MingAudioVAEModel", +] diff --git a/vllm_omni/model_executor/models/ming_tts/aggregator.py b/vllm_omni/model_executor/models/ming_tts/aggregator.py new file mode 100644 index 000000000..3233c9659 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/aggregator.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/fm/dit.py +from __future__ import annotations + +import torch +import torch.nn as nn +from x_transformers.x_transformers import RotaryEmbedding + +from vllm_omni.model_executor.models.common.ming.dit import DiTBlock, FinalLayer + + +class Aggregator(nn.Module): + def __init__( + self, + in_channels=4, + hidden_size=1152, + depth=28, + num_heads=16, + mlp_ratio=4.0, + llm_input_dim=896, + **kwargs, + ): + super().__init__() + self.in_channels = in_channels + self.out_channels = in_channels + self.num_heads = num_heads + + self.word_embedder = nn.Embedding(1, hidden_size) + self.x_embedder = nn.Linear(in_channels, hidden_size) + self.hidden_size = hidden_size + self.rotary_embed = RotaryEmbedding(hidden_size // num_heads) + self.blocks = nn.ModuleList( + [DiTBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, **kwargs) for _ in range(depth)] + ) + self.final_layer = FinalLayer(hidden_size, llm_input_dim) + + def forward(self, x, mask=None): + if x.ndim != 3: + raise ValueError(f"Expected x rank-3 [Batch, Time, Dimension], got {tuple(x.shape)}") + if x.shape[-1] != self.in_channels: + raise ValueError(f"x feature dim mismatch: got {x.shape[-1]}, expected {self.in_channels}") + + # [Batch, Time, Dimension] -> [Batch, Time, Hidden]. + x = self.x_embedder(x) + cls_embed = self.word_embedder(torch.zeros((x.shape[0], 1), dtype=torch.long, device=x.device)) + # Prepend a learned CLS token: [Batch, Time, Hidden] -> [Batch, Time + 1, Hidden]. + x = torch.cat([cls_embed, x], dim=1) + + rope = self.rotary_embed.forward_from_seq_len(x.shape[1]) + if mask is not None: + if mask.ndim != 2: + raise ValueError(f"Expected mask rank-2 [Batch, Time], got {tuple(mask.shape)}") + if mask.shape[0] != x.shape[0] or mask.shape[1] != x.shape[1] - 1: + raise ValueError( + f"Mask shape mismatch: got {tuple(mask.shape)}, expected {(x.shape[0], x.shape[1] - 1)}" + ) + mask_pad = mask.clone().detach()[:, :1] + mask = torch.cat([mask_pad, mask], dim=-1) + for block in self.blocks: + x = block(x, mask, rope) + x = self.final_layer(x) + # Keep the CLS projection only: [Batch, Time + 1, Hidden] -> [Batch, 1, Hidden]. + return x[:, :1, :] + + +__all__ = ["Aggregator"] diff --git a/vllm_omni/model_executor/models/ming_tts/audio_prep.py b/vllm_omni/model_executor/models/ming_tts/audio_prep.py new file mode 100644 index 000000000..b63e70cc8 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/audio_prep.py @@ -0,0 +1,261 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + +import math +from io import BytesIO +from typing import Any + +import torch + +from .config_ming_tts import ( + AUDIO_FRAME_HOP, + LATENT_DIM, + PATCH_SIZE, + SAMPLE_RATE, + VAE_PATCH_SIZE, + VISION_START_TOKEN_ID, + MingTTSConfig, +) + + +def pad_prompt_waveform( + waveform: Any, + *, + patch_size: int = PATCH_SIZE, + sample_rate: int = SAMPLE_RATE, +) -> torch.Tensor: + tensor = coerce_prompt_waveform(waveform) + pad_align = int((float(sample_rate) / 12.5) * int(patch_size)) + new_len = ((int(tensor.shape[-1]) + pad_align - 1) // pad_align) * pad_align + if new_len == int(tensor.shape[-1]): + return tensor + padded = torch.zeros((1, new_len), dtype=tensor.dtype, device=tensor.device) + padded[:, : tensor.shape[-1]] = tensor + return padded + + +def coerce_prompt_waveform(value: Any) -> torch.Tensor: + if value is None: + raise ValueError("prompt waveform cannot be None") + if isinstance(value, torch.Tensor): + tensor = value.detach() + if tensor.ndim == 1: + return tensor.unsqueeze(0).to(torch.float32) + if tensor.ndim == 2: + if tensor.shape[0] != 1: + return tensor.reshape(1, -1).to(torch.float32) + return tensor.to(torch.float32) + raise ValueError(f"Unsupported Ming prompt waveform rank: {tuple(tensor.shape)}") + if isinstance(value, (list, tuple)): + parts = [coerce_prompt_waveform(item) for item in value if item is not None] + if not parts: + raise ValueError("prompt waveform list was empty") + return torch.cat(parts, dim=-1) + return coerce_prompt_waveform(torch.as_tensor(value)) + + +def coerce_speaker_embeddings(value: Any, *, use_zero_spk_emb: bool = False) -> list[torch.Tensor] | None: + if value is None: + return [torch.zeros((192,), dtype=torch.float32)] if use_zero_spk_emb else None + if isinstance(value, torch.Tensor): + tensor = value.detach() + if tensor.ndim == 1: + tensor = tensor.unsqueeze(0) + if tensor.ndim != 2: + raise ValueError(f"Unsupported Ming speaker embedding shape: {tuple(tensor.shape)}") + items = [row.reshape(-1).to(torch.float32).cpu() for row in tensor] + elif isinstance(value, (list, tuple)): + if value and all(not isinstance(item, (list, tuple, torch.Tensor)) for item in value): + items = [torch.as_tensor(value).detach().reshape(-1).to(torch.float32).cpu()] + else: + items = [] + for item in value: + if item is None: + continue + if not isinstance(item, torch.Tensor): + item = torch.as_tensor(item) + items.append(item.detach().reshape(-1).to(torch.float32).cpu()) + else: + return coerce_speaker_embeddings(torch.as_tensor(value), use_zero_spk_emb=use_zero_spk_emb) + if not items: + return [torch.zeros((192,), dtype=torch.float32)] if use_zero_spk_emb else None + for item in items: + if int(item.numel()) != 192: + raise ValueError(f"Ming speaker embedding must have 192 dims, got {int(item.numel())}") + return items + + +def count_prompt_latent_patches( + value: Any, + *, + patch_size: int = PATCH_SIZE, + latent_dim: int = LATENT_DIM, +) -> int: + if value is None: + return 0 + if not isinstance(value, torch.Tensor): + value = torch.as_tensor(value) + latents = value.detach() + if latents.ndim == 3 and latents.shape[0] == 1: + latents = latents.squeeze(0) + if latents.ndim == 3 and latents.shape[-2:] == (patch_size, latent_dim): + return int(latents.shape[0]) + if latents.ndim != 2 or latents.shape[-1] != latent_dim: + raise ValueError(f"Unsupported Ming prompt_latents shape: {tuple(latents.shape)}") + if latents.shape[0] % patch_size != 0: + raise ValueError( + f"Ming prompt_latents frame count must be divisible by patch_size={patch_size}, " + f"got frames={int(latents.shape[0])}" + ) + return int(latents.shape[0] // patch_size) + + +def count_prompt_waveform_patches( + value: Any, + *, + patch_size: int = PATCH_SIZE, + frame_hop: int = AUDIO_FRAME_HOP, + vae_patch_size: int = VAE_PATCH_SIZE, +) -> int: + if value is None: + return 0 + waveform = pad_prompt_waveform(value, patch_size=patch_size) + frame_count = int(math.ceil(float(waveform.shape[-1]) / float(frame_hop))) + latent_frames = int(math.ceil(float(frame_count) / float(vae_patch_size))) + if latent_frames % int(patch_size) != 0: + raise ValueError( + f"Ming prompt waveform produced latent frame count not divisible by patch_size={patch_size}: " + f"frames={latent_frames}" + ) + return int(latent_frames // int(patch_size)) + + +def _normalize_prompt_waveform(value: Any, *, target_sr: int) -> torch.Tensor: + if isinstance(value, bytes): + import torchaudio + + waveform, sr = torchaudio.load(BytesIO(value)) + waveform = waveform[:1].to(torch.float32) + if int(sr) != int(target_sr): + from torchaudio.functional import resample as resample_audio + + waveform = resample_audio(waveform, int(sr), int(target_sr)) + return waveform + + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[1], int): + waveform = coerce_prompt_waveform(value[0]) + if int(value[1]) != int(target_sr): + from torchaudio.functional import resample as resample_audio + + waveform = resample_audio(waveform, int(value[1]), int(target_sr)) + return waveform + + if isinstance(value, dict): + samples = value.get("samples", value.get("array", value.get("waveform"))) + sr = value.get("sample_rate", value.get("sr", target_sr)) + return _normalize_prompt_waveform((samples, int(sr)), target_sr=target_sr) + + return coerce_prompt_waveform(value) + + +def _coerce_prompt_latents( + value: Any, + *, + patch_size: int, + latent_dim: int, +) -> dict[str, torch.Tensor] | None: + if value is None: + return None + if not isinstance(value, torch.Tensor): + value = torch.as_tensor(value) + + latents = value.detach() + if latents.ndim == 3 and latents.shape[0] == 1: + latents = latents.squeeze(0) + + if latents.ndim == 3 and latents.shape[-2:] == (patch_size, latent_dim): + patches = latents + # [Patch, Time, Dimension] -> [Frame, Dimension] for history seeding. + frames = patches.reshape(-1, latent_dim) + return {"patches": patches, "frames": frames} + + if latents.ndim != 2 or latents.shape[-1] != latent_dim: + raise ValueError(f"Unsupported prompt latent shape: {tuple(latents.shape)}") + if latents.shape[0] % patch_size != 0: + raise ValueError( + f"Prompt latent frame count must be divisible by patch_size={patch_size}, " + f"got frames={int(latents.shape[0])}" + ) + # [Frame, Dimension] -> [Patch, Time, Dimension] for Aggregator prompt slots. + patches = latents.reshape(-1, patch_size, latent_dim) if latents.shape[0] > 0 else None + return {"patches": patches, "frames": latents} + + +def _initial_history( + frames: torch.Tensor | None, + *, + history_size: int, + latent_dim: int, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + history = torch.zeros((history_size, latent_dim), device=device, dtype=dtype) + if frames is None or frames.numel() == 0: + return history + frames = frames.to(device=device, dtype=dtype) + take = min(history_size, int(frames.shape[0])) + history[-take:] = frames[-take:] + return history + + +def _take_scalar(value: Any, idx: int) -> float | None: + if not isinstance(value, torch.Tensor) or value.numel() == 0: + return None + return float(value.reshape(-1)[idx].item()) + + +def _find_audio_placeholder_positions(input_ids: torch.Tensor, cfg: MingTTSConfig) -> torch.Tensor: + dummy_pos = (input_ids == cfg.audio_dummy_token_id).nonzero(as_tuple=True)[0] + if dummy_pos.numel() == 0: + return dummy_pos + + audio_start_pos = (input_ids == cfg.audio_start_token_id).nonzero(as_tuple=True)[0] + audio_end_pos = (input_ids == cfg.audio_end_token_id).nonzero(as_tuple=True)[0] + if audio_start_pos.numel() == 0: + return dummy_pos + + start = int(audio_start_pos[0].item()) + end = int(audio_end_pos[0].item()) if audio_end_pos.numel() > 0 else int(input_ids.shape[0]) + keep = (dummy_pos > start) & (dummy_pos < end) + filtered = dummy_pos[keep] + return filtered if filtered.numel() > 0 else dummy_pos + + +def _find_speaker_placeholder_positions(input_ids: torch.Tensor, hf_config: Any) -> list[int]: + vision_start_token_id = getattr(hf_config, "vision_start_token_id", VISION_START_TOKEN_ID) + vision_start_pos = (input_ids == int(vision_start_token_id)).nonzero(as_tuple=True)[0] + if vision_start_pos.numel() == 0: + return [] + + slots = [] + for pos in vision_start_pos: + slot = int(pos.item()) + 1 + if slot < int(input_ids.shape[0]): + slots.append(slot) + return slots + + +__all__ = [ + "coerce_prompt_waveform", + "coerce_speaker_embeddings", + "count_prompt_latent_patches", + "count_prompt_waveform_patches", + "pad_prompt_waveform", + "_coerce_prompt_latents", + "_find_audio_placeholder_positions", + "_find_speaker_placeholder_positions", + "_initial_history", + "_normalize_prompt_waveform", + "_take_scalar", +] diff --git a/vllm_omni/model_executor/models/ming_tts/config_ming_tts.py b/vllm_omni/model_executor/models/ming_tts/config_ming_tts.py new file mode 100644 index 000000000..b42c4fdca --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/config_ming_tts.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Ming dense checkpoint config adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from transformers import PretrainedConfig, Qwen2Config + +from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig + +from .constants import ( + AGGREGATOR_HIDDEN_SIZE, + AUDIO_DUMMY_TOKEN_ID, + AUDIO_END_TOKEN_ID, + AUDIO_EOS_TOKEN_ID, + AUDIO_FRAME_HOP, + AUDIO_START_TOKEN_ID, + DEFAULT_CFG, + DEFAULT_SIGMA, + DEFAULT_TEMPERATURE, + HISTORY_PATCH_SIZE, + KEY_CFG, + KEY_CHUNK_ID, + KEY_DECODE_STEP, + KEY_LAST_STOP_PROB, + KEY_LATENT_HISTORY, + KEY_MAX_DECODE_STEPS, + KEY_MIN_DECODE_STEPS, + KEY_NEXT_EMBEDS, + KEY_PROMPT_LATENT_TAIL, + KEY_PROMPT_LATENTS, + KEY_REQUEST_ID, + KEY_SIGMA, + KEY_SPEAKER_EMBEDDING, + KEY_TEMPERATURE, + KEY_TEXT_MODE, + LATENT_CHUNK_SIZE, + LATENT_DIM, + LATENT_LEFT_CONTEXT, + LLM_HIDDEN_SIZE, + LLM_VOCAB_SIZE, + MAX_DECODE_STEPS, + PATCH_SIZE, + SAMPLE_RATE, + STOP_HEAD_MIN_STEPS, + STOP_HEAD_THRESHOLD, + TEXT_EOS_TOKEN_ID, + VAE_PATCH_SIZE, + VISION_START_TOKEN_ID, +) +from .validation import _coerce_audio_vae_config, _nested_get, _to_plain_dict, validate_ming_tts_config + + +def _coerce_qwen2_config(value: Any) -> Qwen2Config: + if isinstance(value, Qwen2Config): + return value + if isinstance(value, PretrainedConfig): + return Qwen2Config.from_dict(value.to_dict()) + if isinstance(value, dict): + return Qwen2Config.from_dict(dict(value)) + raise TypeError(f"Unsupported llm_config type for Ming dense config: {type(value)!r}") + + +class MingDenseConfig(PretrainedConfig): + # The upstream checkpoint declares model_type="dense". Keep it for HF + # config compatibility; deploy/ming_tts.yaml selects the vLLM-Omni + # pipeline via pipeline: ming_tts. + model_type = "dense" + + def __init__( + self, + llm_config: Qwen2Config | dict[str, Any] | None = None, + ditar_config: dict[str, Any] | None = None, + aggregator_config: dict[str, Any] | None = None, + audio_tokenizer_config: AudioVAEConfig | dict[str, Any] | None = None, + architectures: list[str] | None = None, + **kwargs: Any, + ) -> None: + self.llm_config = _coerce_qwen2_config(llm_config or {}) + self.ditar_config = dict(ditar_config or {}) + self.aggregator_config = dict(aggregator_config or {}) + self.audio_tokenizer_config = _coerce_audio_vae_config(audio_tokenizer_config) + super().__init__(architectures=architectures, **kwargs) + + def get_text_config(self, decoder: bool = False, **kwargs: Any) -> Qwen2Config: + del decoder, kwargs + return self.llm_config + + +@dataclass +class MingTTSConfig: + """Flat config object shared by Stage-1 and Stage-2. Build via from_hf_config().""" + + llm_hidden_size: int = LLM_HIDDEN_SIZE + llm_vocab_size: int = LLM_VOCAB_SIZE + llm_config: dict[str, Any] = field(default_factory=dict) + + latent_dim: int = LATENT_DIM + patch_size: int = PATCH_SIZE + history_patch_size: int = HISTORY_PATCH_SIZE + + ditar_config: dict[str, Any] = field(default_factory=dict) + aggregator_config: dict[str, Any] = field(default_factory=dict) + + audio_tokenizer_config: AudioVAEConfig | None = None + vae_patch_size: int = VAE_PATCH_SIZE + sample_rate: int = SAMPLE_RATE + audio_frame_hop: int = AUDIO_FRAME_HOP + + cfg: float = DEFAULT_CFG + sigma: float = DEFAULT_SIGMA + temperature: float = DEFAULT_TEMPERATURE + stop_head_min_steps: int = STOP_HEAD_MIN_STEPS + stop_head_threshold: float = STOP_HEAD_THRESHOLD + max_decode_steps: int = MAX_DECODE_STEPS + + latent_chunk_size: int = LATENT_CHUNK_SIZE + latent_left_context: int = LATENT_LEFT_CONTEXT + + text_eos_token_id: int = TEXT_EOS_TOKEN_ID + audio_dummy_token_id: int = AUDIO_DUMMY_TOKEN_ID + audio_start_token_id: int = AUDIO_START_TOKEN_ID + audio_end_token_id: int = AUDIO_END_TOKEN_ID + audio_eos_token_id: int = AUDIO_EOS_TOKEN_ID + + @classmethod + def from_hf_config(cls, hf_config: PretrainedConfig) -> MingTTSConfig: + llm_raw = getattr(hf_config, "llm_config", {}) or {} + ditar_raw = getattr(hf_config, "ditar_config", {}) or {} + agg_raw = getattr(hf_config, "aggregator_config", {}) or {} + atc_raw = getattr(hf_config, "audio_tokenizer_config", None) + + llm_dict = _to_plain_dict(llm_raw) + ditar = _to_plain_dict(ditar_raw) + agg = _to_plain_dict(agg_raw) + ditar.setdefault("attn_backend", "torch") + + atc = _coerce_audio_vae_config(atc_raw) + atc_enc_latent_dim = _nested_get(atc, "enc_kwargs", "latent_dim", default=LATENT_DIM) + atc_patch_size = _nested_get(atc, "patch_size", default=VAE_PATCH_SIZE) + atc_sample_rate = _nested_get(atc, "sample_rate", default=SAMPLE_RATE) + + enc_hop_size = _nested_get(atc, "enc_kwargs", "hop_size", default=AUDIO_FRAME_HOP) + + cfg = cls( + llm_hidden_size=llm_dict.get("hidden_size", LLM_HIDDEN_SIZE), + llm_vocab_size=llm_dict.get("vocab_size", LLM_VOCAB_SIZE), + llm_config=llm_dict, + latent_dim=atc_enc_latent_dim, + patch_size=ditar.get("patch_size", PATCH_SIZE), + history_patch_size=ditar.get("history_patch_size", HISTORY_PATCH_SIZE), + ditar_config=ditar, + aggregator_config=agg, + audio_tokenizer_config=atc, + vae_patch_size=atc_patch_size, + sample_rate=atc_sample_rate, + audio_frame_hop=enc_hop_size if enc_hop_size is not None else AUDIO_FRAME_HOP, + ) + return cfg + + def validate(self) -> None: + validate_ming_tts_config(self) + + +__all__ = [ + "AGGREGATOR_HIDDEN_SIZE", + "AUDIO_DUMMY_TOKEN_ID", + "AUDIO_END_TOKEN_ID", + "AUDIO_EOS_TOKEN_ID", + "AUDIO_FRAME_HOP", + "AUDIO_START_TOKEN_ID", + "DEFAULT_CFG", + "DEFAULT_SIGMA", + "DEFAULT_TEMPERATURE", + "HISTORY_PATCH_SIZE", + "KEY_CFG", + "KEY_CHUNK_ID", + "KEY_DECODE_STEP", + "KEY_LAST_STOP_PROB", + "KEY_LATENT_HISTORY", + "KEY_MAX_DECODE_STEPS", + "KEY_MIN_DECODE_STEPS", + "KEY_NEXT_EMBEDS", + "KEY_PROMPT_LATENT_TAIL", + "KEY_PROMPT_LATENTS", + "KEY_REQUEST_ID", + "KEY_SIGMA", + "KEY_SPEAKER_EMBEDDING", + "KEY_TEMPERATURE", + "KEY_TEXT_MODE", + "LATENT_CHUNK_SIZE", + "LATENT_DIM", + "LATENT_LEFT_CONTEXT", + "LLM_HIDDEN_SIZE", + "LLM_VOCAB_SIZE", + "MAX_DECODE_STEPS", + "MingDenseConfig", + "MingTTSConfig", + "PATCH_SIZE", + "SAMPLE_RATE", + "STOP_HEAD_MIN_STEPS", + "STOP_HEAD_THRESHOLD", + "TEXT_EOS_TOKEN_ID", + "VAE_PATCH_SIZE", + "VISION_START_TOKEN_ID", +] diff --git a/vllm_omni/model_executor/models/ming_tts/constants.py b/vllm_omni/model_executor/models/ming_tts/constants.py new file mode 100644 index 000000000..2f59e2094 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/constants.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Token IDs (confirmed from tokenizer_config.json) +# --------------------------------------------------------------------------- + +AUDIO_DUMMY_TOKEN_ID = 151705 # +AUDIO_START_TOKEN_ID = 151706 # +AUDIO_EOS_TOKEN_ID = 151704 # +VISION_START_TOKEN_ID = 151652 # <|vision_start|> + +TEXT_EOS_TOKEN_ID = 151669 # + + +# --------------------------------------------------------------------------- +# Architectural constants (confirmed from original config.json) +# --------------------------------------------------------------------------- + +LATENT_DIM = 64 +PATCH_SIZE = 4 +HISTORY_PATCH_SIZE = 32 +LLM_HIDDEN_SIZE = 896 +LLM_VOCAB_SIZE = 151936 +AGGREGATOR_HIDDEN_SIZE = 1024 +VAE_PATCH_SIZE = 4 +SAMPLE_RATE = 44100 + +# AudioVAE frame/hop geometry (confirmed) +AUDIO_FRAME_HOP = 882 # enc input_dim / hop_size / dec output_dim + +# stop_head defaults +STOP_HEAD_MIN_STEPS = 3 +STOP_HEAD_THRESHOLD = 0.5 + +# FlowLoss sampling defaults +DEFAULT_CFG = 2.0 +DEFAULT_SIGMA = 0.25 +DEFAULT_TEMPERATURE = 0.0 + +# Connector / Stage-2 streaming defaults (runtime tuning) +LATENT_CHUNK_SIZE = 25 +LATENT_LEFT_CONTEXT = 0 +MAX_DECODE_STEPS = 200 + + +# --------------------------------------------------------------------------- +# seq_data.extra_data keys +# --------------------------------------------------------------------------- + +KEY_LATENT_HISTORY = "ming_latent_history" +KEY_DECODE_STEP = "ming_decode_step" +KEY_LAST_STOP_PROB = "ming_last_stop_prob" +KEY_NEXT_EMBEDS = "ming_next_embeds" +KEY_PROMPT_LATENTS = "ming_prompt_latents" +KEY_PROMPT_LATENT_TAIL = "ming_prompt_latent_tail" +KEY_SPEAKER_EMBEDDING = "ming_speaker_embedding" +KEY_REQUEST_ID = "ming_request_id" +KEY_CHUNK_ID = "ming_chunk_id" +KEY_CFG = "ming_cfg" +KEY_SIGMA = "ming_sigma" +KEY_TEMPERATURE = "ming_temperature" +KEY_MAX_DECODE_STEPS = "ming_max_decode_steps" +KEY_MIN_DECODE_STEPS = "ming_min_decode_steps" +KEY_TEXT_MODE = "ming_text_mode" diff --git a/vllm_omni/model_executor/models/ming_tts/flowloss_head.py b/vllm_omni/model_executor/models/ming_tts/flowloss_head.py new file mode 100644 index 000000000..3f300cec0 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/flowloss_head.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/fm/flowloss.py + +import torch +import torch.nn as nn + +from vllm_omni.model_executor.models.common.ming.fm import Solver, build_timesteps + +from .fm.dit import DiT + + +class FlowLoss(nn.Module): + """Diffusion Loss""" + + def __init__(self, z_channels, llm_cond_dim, **kwargs): + super().__init__() + self.z_channels = z_channels + # Preserve checkpoint parameter names under flowloss.cfm.model.* while + # keeping the inference-only CFM sampling logic local to FlowLoss. + self.cfm = nn.Module() + self.cfm.add_module("model", DiT(in_channels=z_channels, llm_cond_dim=llm_cond_dim, **kwargs)) + + @torch.no_grad() + def sample(self, z, latent_history, cfg=2.0, patch_size=1, sigma=0.25, temperature=0): + # z: [Batch, Time=1, Dimension] + # latent_history: [Batch, History_Time, Dimension] + if z.ndim != 3: + raise ValueError(f"Expected conditioning rank-3 [Batch, Time, Dimension], got {tuple(z.shape)}") + if latent_history.ndim != 3: + raise ValueError( + f"Expected latent_history rank-3 [Batch, Time, Dimension], got {tuple(latent_history.shape)}" + ) + if z.shape[0] != latent_history.shape[0]: + raise ValueError( + f"Batch mismatch across conditioning and latent_history: {z.shape[0]}, {latent_history.shape[0]}" + ) + dtype = z.dtype + noise = torch.randn(z.shape[0], self.z_channels, patch_size, device=z.device, dtype=torch.float32) + + def fn(t, x): + x = x.to(dtype) + t = t.to(dtype) + if cfg < 1e-5: + if t.ndim == 0: + t = t.repeat(x.shape[0]) + pred = self.cfm.model(x=x, t=t, c=torch.zeros_like(z), latent_history=latent_history) + return pred[:, -patch_size:, :].float() + + pred_cfg = self.cfm.model.forward_with_cfg( + x=x, t=t, c=z, latent_history=latent_history, cfg_scale=cfg, patch_size=patch_size + ) + pred, null_pred = torch.chunk(pred_cfg, 2, dim=0) + return (pred + (pred - null_pred) * cfg).float() + + t = build_timesteps( + 10, + device=noise.device, + dtype=noise.dtype, + use_epss=True, + sway_sampling_coef=-1.0, + ) + return Solver(fn, noise.transpose(1, 2), sigma=sigma, temperature=temperature).integrate(t)[-1].to(dtype) diff --git a/vllm_omni/model_executor/models/ming_tts/fm/__init__.py b/vllm_omni/model_executor/models/ming_tts/fm/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/fm/__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/model_executor/models/ming_tts/fm/dit.py b/vllm_omni/model_executor/models/ming_tts/fm/dit.py new file mode 100644 index 000000000..9aa05d4a2 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/fm/dit.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/fm/dit.py + +import torch +import torch.nn as nn +from x_transformers.x_transformers import RotaryEmbedding + +from vllm_omni.model_executor.layers.timestep_embedding import DiTTimestepEmbedding +from vllm_omni.model_executor.models.common.ming.dit import CondEmbedder, DiTBlock, FinalLayer + + +class DiT(nn.Module): + def __init__( + self, + in_channels=4, + hidden_size=1024, + depth=28, + num_heads=16, + mlp_ratio=4.0, + llm_cond_dim=896, + **kwargs, + ): + super().__init__() + + self.in_channels = in_channels + self.out_channels = in_channels + self.num_heads = num_heads + self.t_embedder = DiTTimestepEmbedding(hidden_size) + self.x_embedder = nn.Linear(in_channels, hidden_size) + self.c_embedder = CondEmbedder(llm_cond_dim, hidden_size) + self.hidden_size = hidden_size + self.rotary_embed = RotaryEmbedding(hidden_size // num_heads) + self.blocks = nn.ModuleList( + [DiTBlock(hidden_size, num_heads, mlp_ratio=mlp_ratio, **kwargs) for _ in range(depth)] + ) + self.final_layer = FinalLayer(hidden_size, self.out_channels) + + def forward(self, x, t, c, latent_history, mask=None): + if x.ndim != 3: + raise ValueError(f"Expected x rank-3 [Batch, Time, Dimension], got {tuple(x.shape)}") + if latent_history.ndim != 3: + raise ValueError( + f"Expected latent_history rank-3 [Batch, Time, Dimension], got {tuple(latent_history.shape)}" + ) + if c.ndim != 3: + raise ValueError(f"Expected conditioning rank-3 [Batch, Time, Dimension], got {tuple(c.shape)}") + if x.shape[0] != latent_history.shape[0] or x.shape[0] != c.shape[0]: + raise ValueError( + "Batch mismatch across x, conditioning, and latent_history: " + f"{x.shape[0]}, {c.shape[0]}, {latent_history.shape[0]}" + ) + if x.shape[-1] != self.in_channels: + raise ValueError(f"x feature dim mismatch: got {x.shape[-1]}, expected {self.in_channels}") + if latent_history.shape[-1] != self.in_channels: + raise ValueError( + f"latent_history feature dim mismatch: got {latent_history.shape[-1]}, expected {self.in_channels}" + ) + if t.ndim == 0: + t = t.reshape(1) + if t.ndim != 1: + raise ValueError(f"Expected timestep rank-1 [Batch], got {tuple(t.shape)}") + if t.shape[0] != x.shape[0]: + raise ValueError(f"Timestep batch mismatch: got {t.shape[0]}, expected {x.shape[0]}") + + t = self.t_embedder(t).unsqueeze(1) + x_now = self.x_embedder(x) + x_history = self.x_embedder(latent_history) + x = torch.cat([x_history, x_now], dim=1) + c = self.c_embedder(c) + y = t + c + x = torch.cat([y, x], dim=1) + rope = self.rotary_embed.forward_from_seq_len(x.shape[1]) + + if mask is not None: + if mask.ndim != 2: + raise ValueError(f"Expected mask rank-2 [Batch, Time], got {tuple(mask.shape)}") + if mask.shape[0] != x_now.shape[0] or mask.shape[1] != x_now.shape[1]: + raise ValueError( + f"Mask shape mismatch: got {tuple(mask.shape)}, expected {(x_now.shape[0], x_now.shape[1])}" + ) + mask_pad = mask.clone().detach()[:, :1].expand(-1, x_history.shape[1] + c.shape[1]) + mask = torch.cat([mask_pad, mask], dim=-1) + for block in self.blocks: + x = block(x, mask, rope) + x = self.final_layer(x) + return x + + def forward_with_cfg(self, x, t, c, cfg_scale, latent_history, patch_size): + if not cfg_scale == 1: + x = torch.cat([x, x], dim=0) + latent_history = torch.cat([latent_history, latent_history], dim=0) + fake_latent = torch.zeros_like(c) + c = torch.cat([c, fake_latent], dim=0) + if t.ndim == 0: + t = t.repeat(x.shape[0]) + model_out = self.forward(x, t, c, latent_history) + return model_out[:, -patch_size:, :] diff --git a/vllm_omni/model_executor/models/ming_tts/ming_tts.py b/vllm_omni/model_executor/models/ming_tts/ming_tts.py new file mode 100644 index 000000000..667aded27 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/ming_tts.py @@ -0,0 +1,269 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + +from collections.abc import Iterable +from functools import cached_property +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.model_executor.models import SupportsPP +from vllm.model_executor.models.utils import init_vllm_registered_model +from vllm.v1.sample.sampler import Sampler + +from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin + +from .audio_prep import ( + _coerce_prompt_latents, + _find_audio_placeholder_positions, + _find_speaker_placeholder_positions, + _initial_history, + _take_scalar, + coerce_speaker_embeddings, +) +from .config_ming_tts import ( + AUDIO_START_TOKEN_ID, + KEY_CFG, + KEY_DECODE_STEP, + KEY_LAST_STOP_PROB, + KEY_LATENT_HISTORY, + KEY_MAX_DECODE_STEPS, + KEY_MIN_DECODE_STEPS, + KEY_NEXT_EMBEDS, + KEY_PROMPT_LATENT_TAIL, + KEY_REQUEST_ID, + KEY_SIGMA, + KEY_SPEAKER_EMBEDDING, + KEY_TEMPERATURE, + KEY_TEXT_MODE, + MingTTSConfig, +) +from .patch_emission import MING_STOP_REASON_KEY +from .prompt_encoder import _resolve_prompt_latents + + +class _ModelSampleAdapter(nn.Module): + def __init__(self, model: nn.Module): + super().__init__() + self.model = model + + def forward(self, logits, sampling_metadata): + return self.model.sample(logits, sampling_metadata) + + +class MingTTSForConditionalGeneration(nn.Module, SupportsPP, CustomProcessMixin): + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + del prefix + self.vllm_config = vllm_config + self.ming_config = MingTTSConfig.from_hf_config(vllm_config.model_config.hf_config) + self.ming_config.validate() + self.have_multimodal_outputs = True + self.has_preprocess = False + self.has_postprocess = False + self.requires_raw_input_tokens = False + self.model_stage = vllm_config.model_config.model_stage + self._prompt_encoder = None + + if self.model_stage == "llm": + self.model = init_vllm_registered_model(vllm_config=vllm_config, architectures=["MingLLMModel"]) + self.has_preprocess = True + self.has_postprocess = True + self.set_custom_preprocess(self.preprocess) + self.set_custom_postprocess(self.postprocess) + elif self.model_stage == "audio_vae": + self.model = init_vllm_registered_model(vllm_config=vllm_config, architectures=["MingAudioVAEModel"]) + self.requires_raw_input_tokens = True + else: + raise ValueError(f"Invalid Ming model_stage={self.model_stage}") + self.make_empty_intermediate_tensors = getattr(self.model, "make_empty_intermediate_tensors", lambda: None) + + @cached_property + def sampler(self): + if hasattr(self.model, "sample"): + return _ModelSampleAdapter(self.model) + if hasattr(self.model, "sampler"): + return self.model.sampler + return Sampler() + + def embed_input_ids(self, input_ids: torch.Tensor, **kwargs: Any) -> torch.Tensor: + return self.model.embed_input_ids(input_ids=input_ids, **kwargs) + + def forward(self, *args: Any, **kwargs: Any): + return self.model(*args, **kwargs) + + def compute_logits(self, hidden_states, sampling_metadata=None): + return self.model.compute_logits(hidden_states, sampling_metadata=sampling_metadata) + + def sample(self, logits, sampling_metadata): + return self.model.sample(logits, sampling_metadata) if hasattr(self.model, "sample") else None + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + weights = list(weights) + if self.model_stage == "llm": + allowed = ("model.", "linear_proj_audio.", "flowloss.", "stop_head.", "spk_head.") + llm_weights = [(k, v) for k, v in weights if k.startswith(allowed)] + if not llm_weights: + raise RuntimeError( + "Ming Stage-0 received no loadable checkpoint weights. " + "Expected prefixes: model.*, linear_proj_audio.*, flowloss.*, stop_head.*, spk_head.*" + ) + loaded = self.model.load_weights(llm_weights) + return {f"model.{name}" for name in loaded} + + audio_weights = [(k, v) for k, v in weights if k.startswith("audio.")] + if not audio_weights: + raise RuntimeError("Ming Stage-1 received no loadable checkpoint weights. Expected prefix: audio.*") + loaded = self.model.load_weights(audio_weights) + return {f"model.{name}" for name in loaded} + + def _resolve_prompt_latents(self, info_dict: dict[str, Any]): + return _resolve_prompt_latents(self, info_dict) + + def preprocess(self, input_ids: torch.Tensor, input_embeds: torch.Tensor | None, **info_dict: Any): + if self.model_stage != "llm": + return input_ids, input_embeds, {} + input_embeds = self.model.embed_input_ids(input_ids).clone() + return ( + self._prefill_preprocess(input_ids, input_embeds, **info_dict) + if int(input_ids.shape[0]) > 1 + else self._decode_preprocess(input_ids, input_embeds, **info_dict) + ) + + def postprocess(self, hidden_states: torch.Tensor, **info_dict: Any) -> dict[str, Any]: + if self.model_stage != "llm" or hidden_states.numel() == 0: + return {} + req_id = info_dict.get(KEY_REQUEST_ID, info_dict.get("request_id")) + pending = self.model.pop_postprocess_update(req_id) + if not pending or not isinstance(pending.get("ming_latent_patch"), torch.Tensor): + return {} + + update = { + KEY_LATENT_HISTORY: pending[KEY_LATENT_HISTORY].detach().to("cpu").contiguous(), + KEY_NEXT_EMBEDS: pending[KEY_NEXT_EMBEDS].detach().to("cpu").contiguous(), + KEY_DECODE_STEP: int(info_dict.get(KEY_DECODE_STEP, 0)) + 1, + } + stop_prob = _take_scalar(pending.get("ming_stop_prob"), 0) + if stop_prob is not None: + update[KEY_LAST_STOP_PROB] = stop_prob + stop_reason = pending.get(MING_STOP_REASON_KEY) + if isinstance(stop_reason, str): + update[MING_STOP_REASON_KEY] = stop_reason + if isinstance(req_id, str): + update[KEY_REQUEST_ID] = req_id + return update + + def _prefill_preprocess(self, input_ids: torch.Tensor, input_embeds: torch.Tensor, **info_dict: Any): + if bool(info_dict.get(KEY_TEXT_MODE, False)): + update: dict[str, Any] = {KEY_TEXT_MODE: True} + request_id = info_dict.get(KEY_REQUEST_ID, info_dict.get("request_id")) + if request_id is not None: + update[KEY_REQUEST_ID] = request_id + if int(input_ids.shape[0]) > 1 and int(input_ids[-1].item()) == AUDIO_START_TOKEN_ID: + return input_ids[:-1], input_embeds[:-1], update + return input_ids, input_embeds, update + + update: dict[str, Any] = {KEY_DECODE_STEP: int(info_dict.get(KEY_DECODE_STEP, 0))} + prompt_latents = _resolve_prompt_latents(self, info_dict) + history = _initial_history( + prompt_latents["frames"] if prompt_latents is not None else None, + history_size=self.ming_config.history_patch_size, + latent_dim=self.ming_config.latent_dim, + device=input_embeds.device, + dtype=torch.float32, + ) + update[KEY_LATENT_HISTORY] = history.detach().to("cpu").contiguous() + update[KEY_PROMPT_LATENT_TAIL] = update[KEY_LATENT_HISTORY] + + speaker_embedding = info_dict.get(KEY_SPEAKER_EMBEDDING, info_dict.get("speaker_embedding")) + speaker_embeddings = None + if speaker_embedding is not None: + speaker_embeddings = coerce_speaker_embeddings( + speaker_embedding, + use_zero_spk_emb=bool(info_dict.get("use_zero_spk_emb", False)), + ) + if speaker_embeddings is not None and len(speaker_embeddings) > 0: + speaker_slots = _find_speaker_placeholder_positions(input_ids, self.vllm_config.model_config.hf_config) + if len(speaker_slots) < len(speaker_embeddings): + raise RuntimeError( + "Could not locate enough speaker placeholder slots: " + f"found {len(speaker_slots)}, need {len(speaker_embeddings)}" + ) + for speaker_slot, spk in zip(speaker_slots, speaker_embeddings): + spk_proj = self.model.project_speaker_embedding( + spk.to(device=input_embeds.device, dtype=input_embeds.dtype).unsqueeze(0) + ).squeeze(0) + input_embeds[speaker_slot] = spk_proj + + if prompt_latents is not None and prompt_latents["patches"] is not None: + prompt_patches = prompt_latents["patches"].to(dtype=getattr(self.model, "fm_dtype", torch.float32)) + prompt_embeds = self.model.linear_proj_audio(prompt_patches).squeeze(1) + placeholder_pos = _find_audio_placeholder_positions(input_ids, self.ming_config) + take = min(int(placeholder_pos.numel()), int(prompt_embeds.shape[0])) + if take > 0: + input_embeds[placeholder_pos[:take]] = prompt_embeds[:take].to(dtype=input_embeds.dtype) + + request_id = info_dict.get(KEY_REQUEST_ID, info_dict.get("request_id")) + if request_id is not None: + update[KEY_REQUEST_ID] = request_id + _copy_runtime_controls(update, info_dict) + return input_ids, input_embeds, update + + def _decode_preprocess(self, input_ids: torch.Tensor, input_embeds: torch.Tensor, **info_dict: Any): + if bool(info_dict.get(KEY_TEXT_MODE, False)): + update: dict[str, Any] = {KEY_TEXT_MODE: True} + request_id = info_dict.get(KEY_REQUEST_ID, info_dict.get("request_id")) + if request_id is not None: + update[KEY_REQUEST_ID] = request_id + return input_ids, input_embeds, update + + update: dict[str, Any] = {KEY_DECODE_STEP: int(info_dict.get(KEY_DECODE_STEP, 0))} + history = info_dict.get(KEY_LATENT_HISTORY) + if isinstance(history, torch.Tensor): + update[KEY_LATENT_HISTORY] = history.detach().to("cpu").contiguous() + else: + zero_history = torch.zeros( + (self.ming_config.history_patch_size, self.ming_config.latent_dim), + device=input_embeds.device, + dtype=torch.float32, + ) + update[KEY_LATENT_HISTORY] = zero_history.detach().to("cpu").contiguous() + + next_embeds = info_dict.get(KEY_NEXT_EMBEDS) + if isinstance(next_embeds, torch.Tensor) and input_ids.numel() == 1: + if not torch.isfinite(next_embeds).all(): + raise RuntimeError("Non-finite next_embeds before decode preprocess write.") + input_embeds[0] = ( + next_embeds.detach() + .reshape(-1, self.ming_config.llm_hidden_size)[0] + .to( + device=input_embeds.device, + dtype=input_embeds.dtype, + ) + ) + if not torch.isfinite(input_embeds[0]).all(): + raise RuntimeError("Non-finite backbone input_embeds after decode preprocess write.") + + request_id = info_dict.get(KEY_REQUEST_ID, info_dict.get("request_id")) + if request_id is not None: + update[KEY_REQUEST_ID] = request_id + _copy_runtime_controls(update, info_dict) + return input_ids, input_embeds, update + + +def _copy_runtime_controls(update: dict[str, Any], info_dict: dict[str, Any]) -> None: + for key in (KEY_CFG, KEY_SIGMA, KEY_TEMPERATURE, KEY_MAX_DECODE_STEPS, KEY_MIN_DECODE_STEPS): + if key in info_dict: + update[key] = info_dict[key] + + +__all__ = [ + "MingTTSForConditionalGeneration", + "_ModelSampleAdapter", + "_coerce_prompt_latents", + "_find_audio_placeholder_positions", + "_find_speaker_placeholder_positions", + "_initial_history", +] diff --git a/vllm_omni/model_executor/models/ming_tts/ming_tts_audio_vae.py b/vllm_omni/model_executor/models/ming_tts/ming_tts_audio_vae.py new file mode 100644 index 000000000..8b029ffba --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/ming_tts_audio_vae.py @@ -0,0 +1,389 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/audio_tokenizer/modeling_audio_vae.py +from __future__ import annotations + +import os +import time +import warnings +from collections import OrderedDict +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.logger import init_logger +from vllm.model_executor.model_loader.weight_utils import default_weight_loader + +from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAE +from vllm_omni.model_executor.models.output_templates import OmniOutput + +from .config_ming_tts import KEY_CHUNK_ID, KEY_REQUEST_ID, MingTTSConfig +from .patch_emission import MING_STOP_REASON_KEY + +logger = init_logger(__name__) + +MING_FINAL_DECODE_STEP_KEY = "ming_final_decode_step" +_STREAM_STATE_TTL_SECONDS = 15 * 60 +_MAX_STREAM_STATES = 1024 + + +class MingAudioVAEModel(nn.Module): + input_modalities = "audio" + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + del prefix + self.vllm_config = vllm_config + self.ming_config = MingTTSConfig.from_hf_config(vllm_config.model_config.hf_config) + if self.ming_config.audio_tokenizer_config is None: + raise ValueError("MingAudioVAEModel requires audio_tokenizer_config") + self.ming_config.validate() + + self.audio = AudioVAE(self.ming_config.audio_tokenizer_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._past_key_values: dict[str, Any] = {} + self._stream_state: dict[str, tuple[Any, Any, Any]] = {} + self._patch_totals: dict[str, int] = {} + self._sample_totals: dict[str, int] = {} + self._state_access_times: OrderedDict[str, float] = OrderedDict() + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + hidden_size = int(self.ming_config.llm_hidden_size) + if input_ids is None or input_ids.numel() == 0: + return torch.empty((0, hidden_size), device=self.vllm_config.device_config.device, dtype=torch.float32) + return torch.zeros((input_ids.shape[0], hidden_size), device=input_ids.device, dtype=torch.float32) + + def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None: + del hidden_states, sampling_metadata + return None + + def chunked_decode_streaming( + self, + latent_chunk: torch.Tensor, + *, + request_id: str, + finished: bool, + ) -> tuple[torch.Tensor, Any, Any, bool, bool]: + had_past_key_values = request_id in self._past_key_values + had_stream_state = _has_stream_state(self._stream_state.get(request_id)) + stream_state = self._stream_state.get(request_id, (None, None, None)) + past_key_values = self._past_key_values.get(request_id) + waveform_parts: list[torch.Tensor] = [] + + patch_count = int(latent_chunk.shape[0]) + for patch_idx in range(patch_count): + # [Batch, Time, Dimension] = [1, patch_size, latent_dim] + latent_patch = latent_chunk[patch_idx : patch_idx + 1] + is_last_patch = finished and patch_idx == patch_count - 1 + waveform, stream_state, past_key_values = self.audio.decode( + latent_patch, + past_key_values=past_key_values, + use_cache=True, + stream_state=stream_state, + last_chunk=is_last_patch, + ) + waveform_parts.append(waveform.reshape(-1).to(torch.float32)) + + waveform_flat = ( + torch.cat(waveform_parts, dim=0) + if waveform_parts + else torch.zeros((0,), dtype=torch.float32, device=latent_chunk.device) + ) + return waveform_flat, stream_state, past_key_values, had_past_key_values, had_stream_state + + @torch.inference_mode() + def forward( + self, + input_ids: torch.Tensor | None = None, + positions: torch.Tensor | None = None, + intermediate_tensors: Any = None, + inputs_embeds: torch.Tensor | None = None, + model_intermediate_buffer: list[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> OmniOutput: + del input_ids, positions, intermediate_tensors, inputs_embeds + + info_list = model_intermediate_buffer if isinstance(model_intermediate_buffer, list) else None + if info_list is None: + runtime_infos = kwargs.get("runtime_additional_information") + info_list = runtime_infos if isinstance(runtime_infos, list) else None + info_list = info_list or [{}] + num_reqs = max(len(info_list), 1) + sr_tensor = torch.tensor(int(self.ming_config.sample_rate), dtype=torch.int32) + empty = torch.zeros((0,), dtype=torch.float32, device=self.vllm_config.device_config.device) + + outputs: list[torch.Tensor] = [] + srs: list[torch.Tensor] = [] + + for idx in range(num_reqs): + info = info_list[idx] if idx < len(info_list) and isinstance(info_list[idx], dict) else {} + meta = info.get("meta") or {} + kv_meta = info.get("kv_metadata") or {} + has_ming_context = _has_ming_context(info) + if has_ming_context and KEY_REQUEST_ID not in info and KEY_REQUEST_ID not in kv_meta: + raise RuntimeError( + f"Ming Stage-2 received a payload without {KEY_REQUEST_ID}. keys={sorted(info.keys())}" + ) + request_id = _resolve_request_id(info, idx) + self._touch_request_state(request_id) + chunk_id = _coerce_optional_int(_first_present(info.get(KEY_CHUNK_ID), kv_meta.get(KEY_CHUNK_ID))) + finished = _coerce_finished( + _first_present( + meta.get("stream_finished"), + meta.get("finished"), + info.get("stream_finished"), + kv_meta.get("stream_finished"), + torch.tensor(True), + ) + ) + latent = _first_present( + info.get("latent"), + info.get("ming_latent_patches"), + kv_meta.get("ming_latent_patches"), + ) + stripped = bool( + _first_present( + meta.get("_ming_payload_stripped"), + info.get("_ming_payload_stripped"), + kv_meta.get("_ming_payload_stripped"), + False, + ) + ) + if stripped: + raise RuntimeError( + "Ming Stage-2 payload was stripped before model entry. " + f"request_id={request_id} chunk_id={chunk_id} keys={sorted(info.keys())}" + ) + if latent is None: + if has_ming_context and not finished: + raise RuntimeError( + "Ming Stage-2 received no latent chunk for an unfinished request. " + f"request_id={request_id} chunk_id={chunk_id} keys={sorted(info.keys())}" + ) + if finished: + self._clear_request_state(request_id) + outputs.append(empty) + srs.append(sr_tensor) + continue + + latent_tensor = _coerce_latent_chunk( + latent, + device=self.vllm_config.device_config.device, + dtype=next(self.audio.parameters()).dtype, + latent_dim=self.ming_config.latent_dim, + patch_size=self.ming_config.patch_size, + ) + if latent_tensor is None or latent_tensor.numel() == 0: + if not finished: + raise RuntimeError( + "Ming Stage-2 received an empty latent chunk before final flush. " + f"request_id={request_id} chunk_id={chunk_id} latent_shape={_shape_of(latent_tensor)}" + ) + if finished: + self._clear_request_state(request_id) + outputs.append(empty) + srs.append(sr_tensor) + continue + + patch_count = int(latent_tensor.shape[0]) + waveform_flat, stream_state, past_key_values, had_past_key_values, had_stream_state = ( + self.chunked_decode_streaming( + latent_tensor, + request_id=request_id, + finished=finished, + ) + ) + total_patch_count = self._patch_totals.get(request_id, 0) + patch_count + total_waveform_numel = self._sample_totals.get(request_id, 0) + int(waveform_flat.numel()) + self._patch_totals[request_id] = total_patch_count + self._sample_totals[request_id] = total_waveform_numel + if (had_past_key_values or had_stream_state) and waveform_flat.numel() == 0 and not finished: + raise RuntimeError( + "Ming Stage-2 produced an empty waveform after cached streaming state already existed. " + f"request_id={request_id} chunk_id={chunk_id} latent_shape={tuple(latent_tensor.shape)} " + f"had_past_key_values={had_past_key_values} had_stream_state={had_stream_state}" + ) + + if finished: + if os.environ.get("MING_TTS_STAGE1_FINAL_LOG") == "1": + logger.info( + "MING_STAGE1_FINAL %s", + { + "request_id": request_id, + "chunk_id": chunk_id, + "stop_reason": _first_present( + info.get(MING_STOP_REASON_KEY), + kv_meta.get(MING_STOP_REASON_KEY), + ), + "final_decode_step": _coerce_optional_int( + _first_present( + info.get(MING_FINAL_DECODE_STEP_KEY), + kv_meta.get(MING_FINAL_DECODE_STEP_KEY), + ) + ), + "final_chunk_patch_count": patch_count, + "total_patch_count": total_patch_count, + "final_chunk_waveform_numel": int(waveform_flat.numel()), + "total_waveform_numel": total_waveform_numel, + }, + ) + self._clear_request_state(request_id) + else: + self._past_key_values[request_id] = past_key_values + self._stream_state[request_id] = stream_state + + outputs.append(waveform_flat) + srs.append(sr_tensor) + + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={ + "model_outputs": outputs, + "sr": srs, + }, + ) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + weights = list(weights) + if not weights: + raise RuntimeError("MingAudioVAEModel received no checkpoint weights.") + + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded: set[str] = set() + skipped: list[str] = [] + + for name, loaded_weight in weights: + if name not in params_dict: + skipped.append(name) + continue + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded.add(name) + + missing = {name for name in params_dict if name.startswith("audio.")} - loaded + if missing: + raise RuntimeError(f"MingAudioVAEModel: {len(missing)} params not loaded. First few: {sorted(missing)[:5]}") + if skipped: + warnings.warn( + f"MingAudioVAEModel: skipped {len(skipped)} checkpoint keys during load. First few: {skipped[:8]}", + stacklevel=2, + ) + return loaded + + def _clear_request_state(self, request_id: str) -> None: + self._past_key_values.pop(request_id, None) + self._stream_state.pop(request_id, None) + self._patch_totals.pop(request_id, None) + self._sample_totals.pop(request_id, None) + self._state_access_times.pop(request_id, None) + + def _touch_request_state(self, request_id: str) -> None: + now = time.monotonic() + self._evict_expired_request_states(now) + self._state_access_times[request_id] = now + self._state_access_times.move_to_end(request_id) + while len(self._state_access_times) > _MAX_STREAM_STATES: + evicted_request_id, _ = self._state_access_times.popitem(last=False) + self._clear_request_state(evicted_request_id) + + def _evict_expired_request_states(self, now: float) -> None: + cutoff = now - _STREAM_STATE_TTL_SECONDS + while self._state_access_times: + request_id, last_access = next(iter(self._state_access_times.items())) + if last_access >= cutoff: + break + self._clear_request_state(request_id) + + +def _coerce_finished(value: Any) -> bool: + if isinstance(value, torch.Tensor): + if value.numel() == 0: + return False + return bool(value.reshape(-1)[0].item()) + return bool(value) + + +def _first_present(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +def _coerce_optional_int(value: Any, default: int | None = None) -> int | None: + if value is None: + return default + if isinstance(value, list): + value = value[0] if value else default + if isinstance(value, torch.Tensor): + if value.numel() == 0: + return default + return int(value.reshape(-1)[0].item()) + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _resolve_request_id(info: dict[str, Any], idx: int) -> str: + kv_meta = info.get("kv_metadata") or {} + request_id = info.get(KEY_REQUEST_ID) or kv_meta.get(KEY_REQUEST_ID) + if request_id is None: + return str(idx) + if not isinstance(request_id, str) or not request_id: + raise RuntimeError(f"Ming Stage-2 received invalid request id: {request_id!r}") + return request_id + + +def _has_ming_context(info: dict[str, Any]) -> bool: + kv_meta = info.get("kv_metadata") or {} + keys = ( + KEY_REQUEST_ID, + KEY_CHUNK_ID, + "ming_latent_patches", + "_ming_payload_stripped", + ) + return any(key in info or key in kv_meta for key in keys) or "latent" in info + + +def _shape_of(value: Any) -> tuple[int, ...] | None: + if isinstance(value, torch.Tensor): + return tuple(value.shape) + return None + + +def _has_stream_state(value: Any) -> bool: + if not isinstance(value, tuple): + return False + return any(item is not None for item in value) + + +def _coerce_latent_chunk( + value: Any, + *, + device: torch.device, + dtype: torch.dtype, + latent_dim: int, + patch_size: int, +) -> torch.Tensor | None: + if not isinstance(value, torch.Tensor): + value = torch.as_tensor(value) + + latent = value.detach() + if latent.ndim == 2: + latent = latent.unsqueeze(0) + if latent.ndim != 3: + raise ValueError(f"Expected latent chunk rank-3 [Batch, Time, Dimension], got {tuple(latent.shape)}") + if latent.shape[-2] != patch_size: + raise ValueError(f"Latent patch size mismatch: got {latent.shape[-2]}, expected {patch_size}") + if latent.shape[-1] != latent_dim: + raise ValueError(f"Latent dim mismatch: got {latent.shape[-1]}, expected {latent_dim}") + return latent.to(device=device, dtype=dtype) diff --git a/vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py b/vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py new file mode 100644 index 000000000..92daad967 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py @@ -0,0 +1,414 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/modeling_bailingmm.py +from __future__ import annotations + +import warnings +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.logger import init_logger +from vllm.model_executor.models.qwen2 import Qwen2Model +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper, maybe_prefix +from vllm.sequence import IntermediateTensors +from vllm.v1.outputs import SamplerOutput +from vllm.v1.sample.metadata import SamplingMetadata + +from vllm_omni.model_executor.models.output_templates import OmniOutput + +from .aggregator import Aggregator +from .config_ming_tts import ( + KEY_CFG, + KEY_DECODE_STEP, + KEY_LATENT_HISTORY, + KEY_MAX_DECODE_STEPS, + KEY_MIN_DECODE_STEPS, + KEY_NEXT_EMBEDS, + KEY_REQUEST_ID, + KEY_SIGMA, + KEY_TEMPERATURE, + KEY_TEXT_MODE, + MingTTSConfig, +) +from .flowloss_head import FlowLoss +from .patch_emission import ( + MING_STOP_REASON_CODES, + MING_STOP_REASON_CONTINUE, + MING_STOP_REASON_KEY, + MING_STOP_REASON_MAX_DECODE_STEPS, + MING_STOP_REASON_STOP_HEAD, + _coerce_latent_history, + _get_request_token_counts, + _normalize_request_infos, + _resolve_ming_stop_decision, + _resolve_optional_runtime_int, + _resolve_runtime_float, + _resolve_runtime_int, + _validate_ming_decode_window, +) + +logger = init_logger(__name__) + + +class MingLLMModel(nn.Module): + hf_to_vllm_mapper = WeightsMapper( + orig_to_new_prefix={ + "model.model.": "model.", + } + ) + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.ming_config = MingTTSConfig.from_hf_config(vllm_config.model_config.hf_config) + self.ming_config.validate() + self.vllm_config = vllm_config + self.prefix = prefix + self.fm_dtype = _resolve_ming_runtime_dtype(vllm_config) + self.model = Qwen2Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) + self.linear_proj_audio = Aggregator( + in_channels=self.ming_config.latent_dim, + llm_input_dim=self.ming_config.llm_hidden_size, + **self.ming_config.aggregator_config, + ) + self.flowloss = FlowLoss( + z_channels=self.ming_config.latent_dim, + llm_cond_dim=self.ming_config.llm_hidden_size, + **self.ming_config.ditar_config, + ) + self.stop_head = nn.Linear(self.ming_config.llm_hidden_size, 2, bias=True) + self.spk_head = nn.Linear(192, self.ming_config.llm_hidden_size, bias=True) + self.flowloss.to(dtype=self.fm_dtype) + self.linear_proj_audio.to(dtype=self.fm_dtype) + self.stop_head.to(dtype=self.fm_dtype) + self.spk_head.to(dtype=self.fm_dtype) + self._pending_postprocess_updates: dict[str, dict[str, Any]] = {} + self._last_ming_next_token_ids = None + self._last_text_mode = False + + def embed_input_ids( + self, input_ids: torch.Tensor, inputs_embeds: torch.Tensor | None = None, **_: Any + ) -> torch.Tensor: + if inputs_embeds is not None: + return inputs_embeds + return self.model.embed_input_ids(input_ids) + + def project_speaker_embedding(self, spk_emb: torch.Tensor) -> torch.Tensor: + return self.spk_head(spk_emb) + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None = None, + inputs_embeds: torch.Tensor | None = None, + latent_history: torch.Tensor | None = None, + model_intermediate_buffer: list[dict[str, Any]] | None = None, + seq_token_counts: list[int] | None = None, + **kwargs: object, + ) -> OmniOutput | IntermediateTensors | torch.Tensor: + if inputs_embeds is None: + inputs_embeds = self.embed_input_ids(input_ids) + if model_intermediate_buffer is None: + model_intermediate_buffer = kwargs.get("runtime_additional_information") + request_infos = _normalize_request_infos(model_intermediate_buffer) + _validate_ming_decode_window( + request_infos, + min_stop_step=int(self.ming_config.stop_head_min_steps), + default_max_decode_steps=self.ming_config.max_decode_steps, + ) + backbone_out = self.model( + input_ids=input_ids, + positions=positions, + intermediate_tensors=intermediate_tensors, + inputs_embeds=inputs_embeds, + ) + if isinstance(backbone_out, IntermediateTensors): + self._last_ming_next_token_ids = None + return backbone_out + hidden_states = _extract_hidden_states(backbone_out) + token_counts = _get_request_token_counts(hidden_states, request_infos, seq_token_counts) + text_mode = bool(request_infos) and all(bool(info.get(KEY_TEXT_MODE, False)) for info in request_infos) + if request_infos and any(bool(info.get(KEY_TEXT_MODE, False)) for info in request_infos) and not text_mode: + raise RuntimeError("Mixed Ming text/audio modes in one Stage-0 batch are unsupported.") + if text_mode: + self._last_ming_next_token_ids = None + self._last_text_mode = True + return OmniOutput( + text_hidden_states=hidden_states, + multimodal_outputs={KEY_TEXT_MODE: True}, + intermediate_tensors=intermediate_tensors, + ) + self._last_text_mode = False + if latent_history is None and not token_counts: + self._last_ming_next_token_ids = None + return OmniOutput( + text_hidden_states=hidden_states, multimodal_outputs=None, intermediate_tensors=intermediate_tensors + ) + if latent_history is not None and not token_counts: + token_counts, request_infos = [hidden_states.shape[0]], [{KEY_LATENT_HISTORY: latent_history}] + + total_tokens = hidden_states.shape[0] + latent_patch_tokens = next_embed_tokens = new_history_tokens = None + decode_step_tokens = has_patch = stop_reason_code_tokens = None + next_token_ids = [] + pending_updates: dict[str, dict[str, Any]] = {} + cursor = 0 + any_decode = False + for req_idx, token_count in enumerate(token_counts): + end = min(cursor + token_count, total_tokens) + if end <= cursor: + continue + req_info = request_infos[req_idx] if req_idx < len(request_infos) else {} + req_id = req_info.get(KEY_REQUEST_ID) + req_history = _coerce_latent_history( + req_info.get(KEY_LATENT_HISTORY), device=hidden_states.device, dtype=self.fm_dtype, cfg=self.ming_config + ) + if req_history is None: + cursor = end + continue + decode_step = int(req_info.get(KEY_DECODE_STEP, req_info.get("generated_len", 0))) + decode_hidden, output_index = ( + (hidden_states[cursor:end], cursor) if token_count == 1 else (hidden_states[end - 1 : end], end - 1) + ) + sampled_token_latent, next_embeds, new_history, stop_probs = self._decode_one_step( + hidden_states=decode_hidden, + latent_history=req_history, + cfg_scale=_resolve_runtime_float(req_info, KEY_CFG, self.ming_config.cfg), + sigma=_resolve_runtime_float(req_info, KEY_SIGMA, self.ming_config.sigma), + temperature=_resolve_runtime_float(req_info, KEY_TEMPERATURE, self.ming_config.temperature), + ) + req_max_decode_steps = _resolve_runtime_int( + req_info, KEY_MAX_DECODE_STEPS, self.ming_config.max_decode_steps + ) + req_min_decode_steps = _resolve_optional_runtime_int(req_info, KEY_MIN_DECODE_STEPS, 0) + if latent_patch_tokens is None: + latent_patch_tokens = sampled_token_latent.new_zeros( + (total_tokens, self.ming_config.patch_size, self.ming_config.latent_dim) + ) + next_embed_tokens = next_embeds.new_zeros((total_tokens, 1, self.ming_config.llm_hidden_size)) + new_history_tokens = new_history.new_zeros( + (total_tokens, self.ming_config.history_patch_size, self.ming_config.latent_dim) + ) + decode_step_tokens = torch.zeros((total_tokens,), dtype=torch.int32, device=hidden_states.device) + has_patch = torch.zeros((total_tokens,), dtype=torch.bool, device=hidden_states.device) + stop_reason_code_tokens = torch.zeros((total_tokens,), dtype=torch.int32, device=hidden_states.device) + latent_patch_tokens[output_index : output_index + 1] = sampled_token_latent + next_embed_tokens[output_index : output_index + 1] = next_embeds + new_history_tokens[output_index : output_index + 1] = new_history + decode_step_tokens[output_index : output_index + 1] = decode_step + has_patch[output_index : output_index + 1] = True + stop_reason, _, _, _, next_token_id = _resolve_ming_stop_decision( + step=decode_step, + stop_prob=float(stop_probs.reshape(-1)[0].item()), + stop_threshold=float(self.ming_config.stop_head_threshold), + min_stop_step=int(self.ming_config.stop_head_min_steps), + min_decode_steps=req_min_decode_steps, + max_decode_steps=req_max_decode_steps, + audio_dummy_token_id=int(self.ming_config.audio_dummy_token_id), + text_eos_token_id=int(self.ming_config.text_eos_token_id), + ) + stop_reason_code_tokens[output_index : output_index + 1] = MING_STOP_REASON_CODES[stop_reason] + next_token_ids.append(int(next_token_id)) + if isinstance(req_id, str): + pending_updates[req_id] = { + KEY_LATENT_HISTORY: new_history, + KEY_NEXT_EMBEDS: next_embeds, + "ming_latent_patch": sampled_token_latent, + "ming_stop_prob": stop_probs, + MING_STOP_REASON_KEY: stop_reason, + } + any_decode = True + cursor = end + + self._pending_postprocess_updates = pending_updates + if not any_decode: + self._last_ming_next_token_ids = None + return OmniOutput( + text_hidden_states=hidden_states, multimodal_outputs=None, intermediate_tensors=intermediate_tensors + ) + self._last_ming_next_token_ids = next_token_ids + return OmniOutput( + text_hidden_states=hidden_states, + multimodal_outputs={ + "ming_latent_patch": latent_patch_tokens, + "ming_next_embeds": next_embed_tokens, + "ming_new_history": new_history_tokens, + "ming_decode_step": decode_step_tokens, + "ming_has_patch": has_patch, + MING_STOP_REASON_KEY: stop_reason_code_tokens, + }, + intermediate_tensors=intermediate_tensors, + ) + + def pop_postprocess_update(self, req_id: str) -> dict[str, Any]: + return self._pending_postprocess_updates.pop(req_id, {}) if isinstance(req_id, str) else {} + + def compute_logits(self, hidden_states: torch.Tensor, sampling_metadata: SamplingMetadata) -> torch.Tensor | None: + del sampling_metadata + if self._last_text_mode: + return ( + None + if hidden_states is None or hidden_states.numel() == 0 + else self.model.compute_logits(hidden_states) + ) + if hidden_states is None or hidden_states.numel() == 0: + return None + if hidden_states.dim() != 2: + raise RuntimeError( + f"Expected hidden_states rank-2 [B,H] in compute_logits, got {tuple(hidden_states.shape)}" + ) + batch_size = hidden_states.shape[0] + next_token_ids = self._last_ming_next_token_ids + self._last_ming_next_token_ids = None + if next_token_ids is None: + logger.debug( + "Missing Ming forced next-token ids before compute_logits. " + "Using dummy next-token IDs (likely during a profiling or dummy run)." + ) + next_token_ids = [int(self.ming_config.text_eos_token_id)] * batch_size + if len(next_token_ids) != batch_size: + raise RuntimeError( + "Ming forced next-token batch mismatch: " + f"got {len(next_token_ids)} ids for hidden_states batch {batch_size}." + ) + logits = torch.full( + (batch_size, self.ming_config.llm_vocab_size), + float("-inf"), + device=hidden_states.device, + dtype=torch.float32, + ) + for i, next_token_id in enumerate(next_token_ids): + logits[i, int(next_token_id)] = 0.0 + return logits + + def sample(self, logits, sampling_metadata): + if logits is None: + return None + if self._last_text_mode: + return self.model.sample(logits, sampling_metadata) + del sampling_metadata + return SamplerOutput( + sampled_token_ids=logits.argmax(dim=-1, keepdim=True).to(dtype=torch.int32), + logprobs_tensors=None, + ) + + def _decode_one_step( + self, + *, + hidden_states: torch.Tensor, + latent_history: torch.Tensor, + cfg_scale: float, + sigma: float, + temperature: float, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + if hidden_states.dim() != 2: + raise RuntimeError(f"Expected decode hidden_states rank-2 [B,H], got {tuple(hidden_states.shape)}") + if latent_history.dim() != 3: + raise RuntimeError(f"Expected latent_history rank-3 [B,T,D], got {tuple(latent_history.shape)}") + if hidden_states.shape[0] != latent_history.shape[0]: + raise RuntimeError( + "Batch mismatch: " + f"hidden_states B={hidden_states.shape[0]} " + f"vs latent_history B={latent_history.shape[0]}" + ) + # [Batch, Hidden] -> [Batch, Time, Hidden] = [B, 1, H] for FlowLoss conditioning. + z_diff_cond = hidden_states.to(dtype=self.fm_dtype).unsqueeze(1) + if not torch.isfinite(z_diff_cond).all(): + raise RuntimeError("Non-finite z_diff_cond before FlowLoss.sample().") + sampled_token_latent = self.flowloss.sample( + z=z_diff_cond, + latent_history=latent_history, + cfg=cfg_scale, + patch_size=self.ming_config.patch_size, + sigma=sigma, + temperature=temperature, + ) + expected_shape = (hidden_states.shape[0], self.ming_config.patch_size, self.ming_config.latent_dim) + if tuple(sampled_token_latent.shape) != expected_shape: + raise RuntimeError( + f"FlowLoss output shape mismatch: got {tuple(sampled_token_latent.shape)}, expected {expected_shape}" + ) + new_history = torch.cat([latent_history[:, self.ming_config.patch_size :, :], sampled_token_latent], dim=1) + # Aggregator expects [Batch, Time, Dimension] = [B, 4, 64] and returns [B, 1, H]. + next_embeds = self.linear_proj_audio(sampled_token_latent) + stop_probs = self.stop_head(hidden_states.to(dtype=self.fm_dtype)).softmax(dim=-1)[:, 1] + if not torch.isfinite(sampled_token_latent).all(): + raise RuntimeError("Non-finite sampled_token_latent in Ming decode step.") + if not torch.isfinite(next_embeds).all(): + raise RuntimeError("Non-finite next_embeds in Ming decode step.") + if not torch.isfinite(stop_probs).all(): + raise RuntimeError("Non-finite stop_probs in Ming decode step.") + return sampled_token_latent, next_embeds, new_history, stop_probs + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded_params = AutoWeightsLoader(self).load_weights(weights, mapper=self.hf_to_vllm_mapper) + _warn_missing_prefix("flowloss", params_dict, loaded_params, prefix="flowloss.", fatal=True) + _warn_missing_prefix("linear_proj_audio", params_dict, loaded_params, prefix="linear_proj_audio.", fatal=True) + _warn_missing_prefix("stop_head", params_dict, loaded_params, prefix="stop_head.", fatal=True) + _warn_missing_prefix("spk_head", params_dict, loaded_params, prefix="spk_head.", fatal=True) + return loaded_params + + +def _extract_hidden_states(backbone_out: object) -> torch.Tensor: + if isinstance(backbone_out, torch.Tensor): + return backbone_out + if hasattr(backbone_out, "last_hidden_state"): + return backbone_out.last_hidden_state + if isinstance(backbone_out, (tuple, list)) and backbone_out and isinstance(backbone_out[0], torch.Tensor): + return backbone_out[0] + raise TypeError(f"Unsupported backbone forward output type: {type(backbone_out)}") + + +def _resolve_ming_runtime_dtype(vllm_config: VllmConfig) -> torch.dtype: + dtype = getattr(vllm_config.model_config, "dtype", None) + if isinstance(dtype, torch.dtype): + return dtype + if isinstance(dtype, str): + normalized = dtype.strip().lower() + if normalized in ("float16", "half", "torch.float16"): + return torch.float16 + if normalized in ("bfloat16", "bf16", "torch.bfloat16"): + return torch.bfloat16 + if normalized in ("float32", "fp32", "torch.float32"): + return torch.float32 + return torch.float32 + + +def _warn_missing_prefix( + module_name: str, + params_dict: dict[str, nn.Parameter], + loaded_params: set[str], + prefix: str, + fatal: bool = False, +) -> None: + missing = {key for key in params_dict if key.startswith(prefix)} - loaded_params + if not missing: + return + msg = ( + f"MingLLMModel: {len(missing)} {module_name} params not loaded " + f"(prefix={prefix}). First few: {sorted(missing)[:5]}" + ) + if fatal: + raise RuntimeError(msg) + warnings.warn(msg, stacklevel=3) + + +__all__ = [ + "Aggregator", + "FlowLoss", + "MING_STOP_REASON_CODES", + "MING_STOP_REASON_CONTINUE", + "MING_STOP_REASON_KEY", + "MING_STOP_REASON_MAX_DECODE_STEPS", + "MING_STOP_REASON_STOP_HEAD", + "MingLLMModel", + "_coerce_latent_history", + "_extract_hidden_states", + "_resolve_ming_runtime_dtype", + "_resolve_ming_stop_decision", + "_warn_missing_prefix", +] diff --git a/vllm_omni/model_executor/models/ming_tts/patch_emission.py b/vllm_omni/model_executor/models/ming_tts/patch_emission.py new file mode 100644 index 000000000..c0a25a1c1 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/patch_emission.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/inclusionAI/Ming-omni-tts/blob/main/modeling_bailingmm.py +from __future__ import annotations + +from typing import Any + +import torch +from vllm.forward_context import get_forward_context, is_forward_context_available + +from .config_ming_tts import KEY_MAX_DECODE_STEPS, KEY_MIN_DECODE_STEPS, KEY_REQUEST_ID, KEY_TEXT_MODE, MingTTSConfig + +MING_STOP_REASON_CONTINUE = "continue" +MING_STOP_REASON_STOP_HEAD = "stop_head" +MING_STOP_REASON_MAX_DECODE_STEPS = "max_decode_steps" +MING_STOP_REASON_KEY = "ming_stop_reason" +MING_STOP_REASON_CODES = { + MING_STOP_REASON_CONTINUE: 0, + MING_STOP_REASON_STOP_HEAD: 1, + MING_STOP_REASON_MAX_DECODE_STEPS: 2, +} + + +def _normalize_request_infos(model_intermediate_buffer: object) -> list[dict[str, Any]]: + if not isinstance(model_intermediate_buffer, list): + return [] + infos: list[dict[str, Any]] = [] + for item in model_intermediate_buffer: + infos.append(item if isinstance(item, dict) else {}) + return infos + + +def _get_request_token_counts( + hidden_states: torch.Tensor, + request_infos: list[dict[str, Any]], + seq_token_counts: list[int] | None, +) -> list[int]: + if seq_token_counts: + return [int(x) for x in seq_token_counts] + + if is_forward_context_available(): + slices = getattr(get_forward_context(), "ubatch_slices", None) + if slices is not None and len(slices) > 0: + counts: list[int] = [] + for item in slices: + if isinstance(item, int): + counts.append(int(item)) + elif hasattr(item, "stop") and hasattr(item, "start"): + counts.append(int(item.stop) - int(item.start)) + if counts: + return counts + + if request_infos: + if len(request_infos) == hidden_states.shape[0]: + return [1] * hidden_states.shape[0] + return [hidden_states.shape[0]] + + return [] + + +def _coerce_latent_history( + value: object, + *, + device: torch.device, + dtype: torch.dtype, + cfg: MingTTSConfig, +) -> torch.Tensor | None: + if value is None: + return None + if not isinstance(value, torch.Tensor): + value = torch.as_tensor(value) + + history = value.detach() + if history.ndim == 2: + history = history.unsqueeze(0) + if history.ndim != 3: + raise RuntimeError(f"Expected latent_history rank-3 [B,T,D], got {tuple(history.shape)}") + if history.shape[1] != cfg.history_patch_size or history.shape[2] != cfg.latent_dim: + raise RuntimeError( + f"latent_history shape mismatch: got {tuple(history.shape)}, " + f"expected [B,{cfg.history_patch_size},{cfg.latent_dim}]" + ) + return history.to(device=device, dtype=dtype) + + +def _resolve_runtime_float(req_info: dict[str, Any], key: str, default_value: float) -> float: + raw = req_info.get(key, default_value) + try: + value = float(raw) + except (TypeError, ValueError) as exc: + raise RuntimeError(f"Invalid {key}: expected float-like value, got {raw!r}") from exc + if not value >= 0.0: + raise RuntimeError(f"Invalid {key}: expected non-negative value, got {value}") + return value + + +def _resolve_runtime_int(req_info: dict[str, Any], key: str, default_value: int) -> int: + raw = req_info.get(key, default_value) + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise RuntimeError(f"Invalid {key}: expected int-like value, got {raw!r}") from exc + if value <= 0: + raise RuntimeError(f"Invalid {key}: expected positive value, got {value}") + return value + + +def _resolve_optional_runtime_int(req_info: dict[str, Any], key: str, default_value: int) -> int: + raw = req_info.get(key, default_value) + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise RuntimeError(f"Invalid {key}: expected int-like value, got {raw!r}") from exc + if value < 0: + raise RuntimeError(f"Invalid {key}: expected non-negative value, got {value}") + return value + + +def _validate_ming_decode_window( + request_infos: list[dict[str, Any]], + *, + min_stop_step: int, + default_max_decode_steps: int, +) -> None: + for i, info in enumerate(request_infos): + if info.get(KEY_TEXT_MODE): + continue + max_steps = _resolve_runtime_int(info, KEY_MAX_DECODE_STEPS, default_max_decode_steps) + min_steps = _resolve_optional_runtime_int(info, KEY_MIN_DECODE_STEPS, 0) + min_required = max(min_stop_step + 1, min_steps) + if max_steps < min_required: + req_id = info.get(KEY_REQUEST_ID, f"idx={i}") + raise ValueError( + f"Ming request {req_id!r}: max_decode_steps={max_steps} < " + f"min_required_decode_steps={min_required} " + f"(min_stop_step={min_stop_step}, min_decode_steps={min_steps})" + ) + + +def _resolve_ming_stop_decision( + *, + step: int, + stop_prob: float, + stop_threshold: float, + min_stop_step: int, + min_decode_steps: int, + max_decode_steps: int, + audio_dummy_token_id: int, + text_eos_token_id: int, +) -> tuple[str, bool, bool, int, int]: + min_required_decode_steps = max(min_stop_step + 1, min_decode_steps) + if max_decode_steps < min_required_decode_steps: + raise RuntimeError( + "Invalid Ming decode window: " + f"max_decode_steps={max_decode_steps} is smaller than " + f"min_required_decode_steps={min_required_decode_steps}" + ) + should_force_stop = (step + 1) >= max_decode_steps + should_stop_head = ((step + 1) >= min_required_decode_steps) and stop_prob > stop_threshold + + if should_force_stop: + return ( + MING_STOP_REASON_MAX_DECODE_STEPS, + True, + True, + min_required_decode_steps, + text_eos_token_id, + ) + if should_stop_head: + return ( + MING_STOP_REASON_STOP_HEAD, + True, + False, + min_required_decode_steps, + text_eos_token_id, + ) + return ( + MING_STOP_REASON_CONTINUE, + False, + False, + min_required_decode_steps, + audio_dummy_token_id, + ) diff --git a/vllm_omni/model_executor/models/ming_tts/pipeline.py b/vllm_omni/model_executor/models/ming_tts/pipeline.py new file mode 100644 index 000000000..31ec98e5a --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/pipeline.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Ming TTS pipeline: Stage-0 LLM+flow -> Stage-1 audio VAE.""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_PROC = "vllm_omni.model_executor.stage_input_processors.ming_tts" + +MING_TTS_PIPELINE = PipelineConfig( + model_type="ming_tts", + model_arch="MingTTSForConditionalGeneration", + hf_architectures=("MingTTSForConditionalGeneration",), + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="llm", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + hf_config_name="llm_config", + engine_output_type="latent", + async_chunk_process_next_stage_input_func=(f"{_PROC}.llm2audio_vae_async_chunk"), + sampling_constraints={ + "temperature": 0.0, + "top_p": 1.0, + "top_k": -1, + "max_tokens": 512, + "detokenize": True, + }, + ), + StagePipelineConfig( + stage_id=1, + model_stage="audio_vae", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="audio", + hf_config_name="llm_config", + engine_output_type="audio", + sync_process_input_func=f"{_PROC}.llm2audio_vae", + sampling_constraints={ + "temperature": 0.0, + "top_p": 1.0, + "top_k": -1, + "max_tokens": 1, + "detokenize": False, + }, + ), + ), +) diff --git a/vllm_omni/model_executor/models/ming_tts/prompt_assembly.py b/vllm_omni/model_executor/models/ming_tts/prompt_assembly.py new file mode 100644 index 000000000..c732e07a0 --- /dev/null +++ b/vllm_omni/model_executor/models/ming_tts/prompt_assembly.py @@ -0,0 +1,274 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + +import copy +import json +import math +import re +from typing import Any + +import torch + +from .audio_prep import ( + coerce_speaker_embeddings, + count_prompt_latent_patches, + count_prompt_waveform_patches, + pad_prompt_waveform, +) +from .config_ming_tts import ( + AUDIO_FRAME_HOP, + KEY_MAX_DECODE_STEPS, + KEY_MIN_DECODE_STEPS, + KEY_PROMPT_LATENTS, + KEY_REQUEST_ID, + KEY_SPEAKER_EMBEDDING, + LATENT_DIM, + PATCH_SIZE, + SAMPLE_RATE, + VAE_PATCH_SIZE, +) + +DEFAULT_PROMPT = "Please generate speech based on the following description.\n" +BASE_CAPTION_TEMPLATE: dict[str, Any] = { + "audio_sequence": [ + { + "序号": 1, + "说话人": "speaker_1", + "方言": None, + "风格": None, + "语速": None, + "基频": None, + "音量": None, + "情感": None, + "BGM": { + "Genre": None, + "Mood": None, + "Instrument": None, + "Theme": None, + "ENV": None, + "SNR": None, + }, + "IP": None, + } + ] +} +_DURATION_SECONDS_RE = re.compile(r"Duration:\s*([0-9]+(?:\.[0-9]+)?)\s*s\b", re.IGNORECASE) + + +def create_instruction(user_input: Any) -> str | None: + if user_input is None: + return None + if isinstance(user_input, str): + return user_input + if not isinstance(user_input, dict): + raise ValueError(f"Ming instruction must be str, dict, or None; got {type(user_input).__name__}") + caption = copy.deepcopy(BASE_CAPTION_TEMPLATE) + item = caption["audio_sequence"][0] + for key, value in user_input.items(): + if key in item: + item[key] = value + return json.dumps(caption, ensure_ascii=False) + + +def parse_duration_seconds(text: str | None) -> float | None: + if not isinstance(text, str): + return None + match = _DURATION_SECONDS_RE.search(text) + if match is None: + return None + try: + value = float(match.group(1)) + except ValueError: + return None + if value <= 0.0: + return None + return value + + +def estimate_decode_steps_for_duration( + duration_seconds: float, + *, + sample_rate: int = SAMPLE_RATE, + frame_hop: int = AUDIO_FRAME_HOP, + patch_size: int = PATCH_SIZE, + vae_patch_size: int = VAE_PATCH_SIZE, +) -> int: + if duration_seconds <= 0.0: + return 0 + samples_per_decode_step = int(frame_hop) * int(patch_size) * int(vae_patch_size) + required_samples = float(duration_seconds) * float(sample_rate) + return max(1, int(math.ceil(required_samples / float(samples_per_decode_step)))) + + +def estimate_decode_step_window_for_duration(duration_seconds: float) -> tuple[int, int]: + target_steps = estimate_decode_steps_for_duration(duration_seconds) + min_steps = max(1, target_steps - 3) + max_steps = max(min_steps, target_steps + 3) + return min_steps, max_steps + + +def resolve_effective_runtime_controls( + *, + text: str, + runtime_controls: dict[str, Any] | None = None, +) -> dict[str, Any]: + controls = {} if runtime_controls is None else dict(runtime_controls) + has_explicit_min = KEY_MIN_DECODE_STEPS in controls and controls[KEY_MIN_DECODE_STEPS] is not None + has_explicit_max = KEY_MAX_DECODE_STEPS in controls and controls[KEY_MAX_DECODE_STEPS] is not None + if has_explicit_min or has_explicit_max: + return controls + duration_seconds = parse_duration_seconds(text) + if duration_seconds is None: + return controls + min_decode_steps, max_decode_steps = estimate_decode_step_window_for_duration(duration_seconds) + controls[KEY_MIN_DECODE_STEPS] = min_decode_steps + controls[KEY_MAX_DECODE_STEPS] = max_decode_steps + return controls + + +def build_dense_prompt_token_ids( + tokenizer: Any, + *, + prompt: str, + text: str, + instruction: str | None = None, + prompt_text: str | None = None, + speaker_count: int = 0, + prompt_patch_count: int = 0, +) -> list[int]: + speaker_prompt = [] + for idx in range(int(speaker_count)): + speaker_prompt.extend( + tokenizer.encode(f" speaker_{idx + 1}:") + + tokenizer.encode("<|vision_start|>") + + tokenizer.encode("<|vision_pad|>") + + tokenizer.encode("<|vision_end|>\n") + ) + instruction_prompt = ( + tokenizer.encode(instruction) + tokenizer.encode("<|endoftext|>") if instruction is not None else [] + ) + prompt_text_tokens = ( + tokenizer.encode(prompt_text) if int(prompt_patch_count) > 0 and prompt_text is not None else [] + ) + audio_patch_token_id = tokenizer.convert_tokens_to_ids("") + if audio_patch_token_id == tokenizer.unk_token_id: + raise ValueError("Ming tokenizer is missing required token.") + prompt_latent_tokens = [audio_patch_token_id] * int(prompt_patch_count) + text_input_prefix = ( + [] + if all(token in text for token in ("Genre: ", "Mood: ", "Instrument: ", "Theme: ", "Duration: ")) + else tokenizer.encode(" Text input:\n") + ) + return ( + tokenizer.encode("<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n") + + tokenizer.encode("<|im_start|>user\n") + + tokenizer.encode(prompt) + + speaker_prompt + + text_input_prefix + + prompt_text_tokens + + tokenizer.encode(text) + + tokenizer.encode("<|im_end|>\n") + + tokenizer.encode("<|im_start|>assistant\n") + + instruction_prompt + + tokenizer.encode("

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 Ph##W*0JL%qnsYpSDlz*i0o< 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%0A&cPhV5L4ax5D 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 JU- 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˖L%9^`y`o+b!~TTo4sJ}}m)@+Ks{2!`w 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 Date: Mon, 8 Jun 2026 14:14:02 +0800 Subject: [PATCH 23/75] [Refactor]Refactoring audio_in_video implementation (#3566) Signed-off-by: amy-why-3459 --- vllm_omni/entrypoints/chat_utils.py | 62 --- vllm_omni/entrypoints/openai/serving_chat.py | 121 +----- .../qwen2_5_omni/qwen2_5_omni_thinker.py | 356 +++++++++--------- .../qwen3_omni/qwen3_omni_moe_thinker.py | 16 +- 4 files changed, 206 insertions(+), 349 deletions(-) delete mode 100644 vllm_omni/entrypoints/chat_utils.py diff --git a/vllm_omni/entrypoints/chat_utils.py b/vllm_omni/entrypoints/chat_utils.py deleted file mode 100644 index 4c3d311ec..000000000 --- a/vllm_omni/entrypoints/chat_utils.py +++ /dev/null @@ -1,62 +0,0 @@ -import numpy as np - - -async def extract_audio_from_video_async(video_url: str) -> tuple[np.ndarray, int | float]: - """Extract audio from a video URL using vllm's load_audio. - - Returns a (audio_array, sample_rate) tuple compatible with audio format. - All blocking I/O operations are run in a thread pool. - """ - import asyncio - import os - import tempfile - from urllib.parse import urlparse - - parsed_url = urlparse(video_url) - temp_video_file_path = None - - def _download_video_sync(url: str) -> bytes: - from urllib.request import urlopen - - return urlopen(url).read() - - def _write_temp_file_sync(data: bytes, suffix: str) -> str: - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: - temp_file.write(data) - return temp_file.name - - def _load_audio_sync(file_path: str) -> tuple[np.ndarray, int | float]: - from vllm.multimodal.media.audio import load_audio - - return load_audio(file_path, sr=16000) - - def _cleanup_file_sync(file_path: str) -> None: - try: - if os.path.exists(file_path): - os.unlink(file_path) - except OSError: - pass - - try: - if parsed_url.scheme in ("http", "https"): - video_data = await asyncio.to_thread(_download_video_sync, video_url) - temp_video_file_path = await asyncio.to_thread(_write_temp_file_sync, video_data, ".mp4") - elif parsed_url.scheme == "file": - from urllib.request import url2pathname - - temp_video_file_path = url2pathname(parsed_url.path) - elif parsed_url.scheme == "data": - import base64 - - header, data = video_url.split(",", 1) - video_data = base64.b64decode(data) - temp_video_file_path = await asyncio.to_thread(_write_temp_file_sync, video_data, ".mp4") - else: - # Assume local file path - temp_video_file_path = video_url - - audio_array, sample_rate = await asyncio.to_thread(_load_audio_sync, temp_video_file_path) - return audio_array, sample_rate - finally: - if temp_video_file_path and parsed_url.scheme in ("http", "https", "data"): - await asyncio.to_thread(_cleanup_file_sync, temp_video_file_path) diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py index 9bac6c9ba..5dec1768a 100755 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -12,8 +12,16 @@ from typing import Any, Final, cast import jinja2 import torch from fastapi import Request +from openai.types.chat.chat_completion_audio import ChatCompletionAudio as OpenAIChatCompletionAudio from PIL import Image from pydantic import TypeAdapter +from vllm.entrypoints.chat_utils import ( + ChatCompletionMessageParam, + ChatTemplateContentFormatOption, + ConversationMessage, + get_history_tool_calls_cnt, + make_tool_call_id, +) from vllm_omni.diffusion.diffusion_engine import get_extra_body_params, get_extra_output_params from vllm_omni.entrypoints.async_omni import AsyncOmni @@ -32,14 +40,6 @@ except ImportError: soundfile = None -from openai.types.chat.chat_completion_audio import ChatCompletionAudio as OpenAIChatCompletionAudio -from vllm.entrypoints.chat_utils import ( - ChatCompletionMessageParam, - ChatTemplateContentFormatOption, - ConversationMessage, - get_history_tool_calls_cnt, - make_tool_call_id, -) from vllm.entrypoints.launcher import terminate_if_errored from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedToolChoiceParam, @@ -652,19 +652,15 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): ) tok_params = request.build_tok_params(self.model_config) + mm_config = self.model_config.multimodal_config chat_params = request.build_chat_params( default_template, default_template_content_format, - ).with_defaults(default_template_kwargs) - - # OMNI: When use_audio_in_video=True, the qwen2_5_omni_thinker mm - # processor asserts that audio items are present alongside video items - # (inside render_chat_async). We must inject audio_url items into the - # messages BEFORE calling render_chat_async so the mm processor can - # count them correctly during tokenisation. - mm_proc_kw = getattr(request, "mm_processor_kwargs", None) or {} - if mm_proc_kw.get("use_audio_in_video", False): - messages = await self._inject_audio_from_video_urls(messages) + ).with_defaults( + default_template_kwargs, + default_media_io_kwargs=(mm_config.media_io_kwargs if mm_config else None), + default_mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None), + ) (conversation,), (engine_prompt,) = await renderer.render_chat_async( [messages], @@ -735,95 +731,6 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): return conversation, [engine_prompt] - async def _inject_audio_from_video_urls( - self, - messages: list[ChatCompletionMessageParam], - ) -> list[ChatCompletionMessageParam]: - """Pre-extract audio from video URLs and inject as audio_url content items. - - When use_audio_in_video=True, the qwen2_5_omni_thinker multimodal - processor requires that the number of audio items equals the number of - video items (it subtracts mm_counts["video"] from mm_counts["audio"]). - The client only sends video_url items; this method adds the matching - audio_url items on the server side before the renderer processes them. - """ - import io - - from vllm_omni.entrypoints.chat_utils import extract_audio_from_video_async - - new_messages: list[ChatCompletionMessageParam] = [] - for msg in messages: - content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None) - if not isinstance(content, list): - new_messages.append(msg) - continue - - video_urls = [ - part.get("video_url", {}).get("url") - for part in content - if isinstance(part, dict) and part.get("type") == "video_url" and part.get("video_url", {}).get("url") - ] - - if not video_urls: - new_messages.append(msg) - continue - - audios = await asyncio.gather(*(extract_audio_from_video_async(u) for u in video_urls)) - - audio_items: list[dict] = [] - for audio_array, sample_rate in audios: - buf = io.BytesIO() - if soundfile is not None: - soundfile.write(buf, audio_array, samplerate=int(sample_rate), format="WAV") - else: - import struct - - import numpy as np - - audio_np = np.asarray(audio_array, dtype=np.float32) - sr = int(sample_rate) - num_channels = 1 - bits_per_sample = 32 - num_frames = len(audio_np) - data_size = num_frames * num_channels * (bits_per_sample // 8) - # Write minimal RIFF/WAV header - buf.write(b"RIFF") - buf.write(struct.pack(" list[Any]: """Convert request dicts to stage-typed sampling params objects. 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 931049d8d..5d58a63d4 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,8 +1,9 @@ -"""Thin Omni wrapper: reuse upstream Qwen2.5-Omni thinker (v0.14) with minimal overrides.""" +"""Thin Omni wrapper: reuse upstream Qwen2.5-Omni thinker with minimal overrides.""" -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Iterator, Mapping from typing import Any +import numpy as np import torch from torch import nn from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import ( @@ -49,9 +50,7 @@ from vllm.model_executor.models.utils import ( WeightsMapper, init_vllm_registered_model, maybe_prefix, - split_list_into_ranges, ) -from vllm.model_executor.models.vision import get_llm_pos_ids_for_vision from vllm.multimodal import MULTIMODAL_REGISTRY from vllm.multimodal.inputs import ( MultiModalFeatureSpec, @@ -92,20 +91,15 @@ class Qwen2_5OmniThinkerMultiModalProcessor( self._validate_mm_kwargs(mm_kwargs, mm_item_counts) self._validate_mm_updates(mm_prompt_updates, mm_item_counts) + # Detect use_audio_in_video from mm_kwargs use_audio_in_video = False if "video" in mm_kwargs: - non_none_items = [item for item in mm_kwargs["video"] if item is not None] - if non_none_items: - for item in non_none_items: - if item.get("use_audio_in_video"): - uaiv_tensor = item["use_audio_in_video"].data - if uaiv_tensor.numel() > 0: - use_audio_in_video = bool(uaiv_tensor.item()) - break - elif "audio" in mm_prompt_updates: - tokenizer = self.info.get_tokenizer() - audio_pad_id = tokenizer.convert_tokens_to_ids("<|audio_pad|>") - use_audio_in_video = audio_pad_id not in prompt_ids + for item in mm_kwargs["video"]: + if item and item.get("use_audio_in_video"): + use_audio_in_video_tensor = item["use_audio_in_video"].data + if use_audio_in_video_tensor.numel() > 0: + use_audio_in_video = bool(use_audio_in_video_tensor.item()) + break # for mutilmodality cache if any(item is None for item in mm_kwargs["video"]): video_token_id = self.info.get_hf_config().video_token_id @@ -421,167 +415,193 @@ class Qwen2_5OmniThinkerForConditionalGeneration( def get_language_model(self) -> torch.nn.Module: return self.language_model + def _get_audio_for_video_mapping(self, mm_features: list[MultiModalFeatureSpec]) -> tuple[dict[int, int], set[int]]: + """ + Map video offset -> paired audio_feature_length for use_audio_in_video. + + When use_audio_in_video=True, audio is interleaved within video chunks. + The pairing is based on feature order in mm_features. + """ + videos_with_audio = [ + f + for f in mm_features + if f.modality == "video" and f.data.get("use_audio_in_video") and f.data["use_audio_in_video"].data.item() + ] + audios = [f for f in mm_features if f.modality == "audio"] + + mapping: dict[int, int] = {} + paired_audio_offsets: set[int] = set() + for i, video_f in enumerate(videos_with_audio): + if i < len(audios): + audio_len = audios[i].data["audio_feature_lengths"].data.item() + mapping[video_f.mm_position.offset] = audio_len + paired_audio_offsets.add(audios[i].mm_position.offset) + return mapping, paired_audio_offsets + + def _compute_audio_token_count(self, audio_feature_length: int) -> int: + return ((audio_feature_length - 1) // 2 + 1 - 2) // 2 + 1 + + def iter_mm_features(self, mm_features: list[MultiModalFeatureSpec]) -> Iterator[tuple[int, str, dict[str, Any]]]: + thinker_config = self.config + spatial_merge_size = thinker_config.vision_config.spatial_merge_size + tokens_per_second = getattr(thinker_config.vision_config, "tokens_per_second", 25) + + sorted_features = sorted(mm_features, key=lambda f: f.mm_position.offset) + audio_for_video, paired_audio_offsets = self._get_audio_for_video_mapping(sorted_features) + + for mm_feature in sorted_features: + offset = mm_feature.mm_position.offset + modality = mm_feature.modality + + if modality == "image": + t, h, w = mm_feature.data["image_grid_thw"].data.tolist() + yield ( + offset, + "image", + { + "grid_t": t, + "grid_h": h // spatial_merge_size, + "grid_w": w // spatial_merge_size, + "t_factor": 1.0 * tokens_per_second, + }, + ) + elif modality == "video": + t, h, w = mm_feature.data["video_grid_thw"].data.tolist() + second_per_grid_ts = 1.0 + if mm_feature.data.get("second_per_grid_ts"): + second_per_grid_ts = mm_feature.data["second_per_grid_ts"].data.item() + use_audio_in_video = False + if mm_feature.data.get("use_audio_in_video"): + use_audio_in_video = bool(mm_feature.data["use_audio_in_video"].data.item()) + + yield ( + offset, + "video", + { + "grid_t": t, + "grid_h": h // spatial_merge_size, + "grid_w": w // spatial_merge_size, + "t_factor": second_per_grid_ts * tokens_per_second, + "use_audio_in_video": use_audio_in_video, + "audio_feature_length": audio_for_video.get(offset), + }, + ) + elif modality == "audio": + if offset not in paired_audio_offsets: + audio_len = mm_feature.data["audio_feature_lengths"].data.item() + yield offset, "audio", {"audio_feature_length": audio_len} + + def _compute_interleaved_positions(self, start_idx: int, data: dict[str, Any]) -> tuple[np.ndarray, int]: + grid_t = data["grid_t"] + grid_h = data["grid_h"] + grid_w = data["grid_w"] + t_factor = data["t_factor"] + audio_len = data["audio_feature_length"] + + thinker_config = self.config + tokens_per_second = getattr(thinker_config.vision_config, "tokens_per_second", 25) + seconds_per_chunk = thinker_config.seconds_per_chunk + t_ntoken_per_chunk = int(tokens_per_second * seconds_per_chunk) + + t_index = (np.arange(grid_t) * t_factor).astype(np.int64) + t_index_split_chunk: list[list[int]] = [[] for _ in range((int(t_index.max()) // t_ntoken_per_chunk) + 1)] + for t_val in t_index: + idx = int(t_val) // t_ntoken_per_chunk + t_index_split_chunk[idx].append(int(t_val)) + + pure_audio_len = self._compute_audio_token_count(audio_len) + added_audio_len = 0 + pos_ids_list: list[np.ndarray] = [] + audio_start_idx = start_idx + + for t_chunk in t_index_split_chunk: + if not t_chunk: + continue + + chunk_t = len(t_chunk) + + h_indices = np.tile(np.arange(grid_h).reshape(1, -1, 1), (chunk_t, 1, grid_w)).flatten() + w_indices = np.tile(np.arange(grid_w).reshape(1, 1, -1), (chunk_t, grid_h, 1)).flatten() + t_indices = np.repeat(np.array(t_chunk), grid_h * grid_w) + + vision_pos = np.stack([t_indices, h_indices, w_indices]) + start_idx + pos_ids_list.append(vision_pos) + + audio_chunk_size = min(t_ntoken_per_chunk, pure_audio_len - added_audio_len) + if audio_chunk_size > 0: + audio_pos = np.broadcast_to(np.arange(audio_chunk_size), (3, audio_chunk_size)) + audio_start_idx + pos_ids_list.append(audio_pos) + audio_start_idx = audio_start_idx + audio_chunk_size + added_audio_len += audio_chunk_size + + if added_audio_len < pure_audio_len: + remaining = pure_audio_len - added_audio_len + remaining_audio_pos = np.broadcast_to(np.arange(remaining), (3, remaining)) + audio_start_idx + pos_ids_list.append(remaining_audio_pos) + + vision_tokens = grid_t * grid_h * grid_w + total_tokens = vision_tokens + pure_audio_len + + return np.concatenate(pos_ids_list, axis=1), total_tokens + def get_mrope_input_positions( self, input_tokens: list[int], mm_features: list[MultiModalFeatureSpec], ) -> tuple[torch.Tensor, int]: - """ - Example: + llm_pos_ids_list: list[np.ndarray] = [] + st = 0 - (V_i are vision position ids, A_i are audio position ids) + for offset, modality, data in self.iter_mm_features(mm_features): + text_len = offset - st + st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0 + if text_len > 0: + llm_pos_ids_list.append(np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx) + st_idx += text_len - |V_1 ... V_n|A_1 ... A_n|V_n+1 ... V_2n|A_n+1 ... A_2n|... - |vision chunk 1|audio chunk 1|vision chunk 2|audio chunk 2 |... - """ - kwargs = MultiModalFeatureSpec.gather_kwargs( - mm_features, - { - "image_grid_thw", - "video_grid_thw", - "second_per_grid_ts", - "audio_feature_lengths", - "use_audio_in_video", - }, - ) - image_grid_thw = kwargs.get("image_grid_thw", []) - video_grid_thw = kwargs.get("video_grid_thw", []) - second_per_grid_ts = kwargs.get("second_per_grid_ts", []) - audio_feature_lengths = kwargs.get("audio_feature_lengths", []) - use_audio_in_video = any(kwargs.get("use_audio_in_video", [])) + if modality == "audio": + audio_tokens = self._compute_audio_token_count(data["audio_feature_length"]) + llm_pos_ids_list.append(np.broadcast_to(np.arange(audio_tokens), (3, audio_tokens)) + st_idx) + st = offset + audio_tokens - image_grid_thw = (torch.stack if image_grid_thw else torch.tensor)(image_grid_thw) - video_grid_thw = (torch.stack if video_grid_thw else torch.tensor)(video_grid_thw) + elif modality == "image": + grid_t = data["grid_t"] + grid_h = data["grid_h"] + grid_w = data["grid_w"] + t_factor = data["t_factor"] - # TODO(fyabc): refactor and share more code with - # _vl_get_input_positions_tensor. + grid_indices = np.indices((grid_t, grid_h, grid_w)) + if t_factor != 1.0: + grid_indices[0] = (grid_indices[0] * t_factor).astype(np.int64) + llm_pos_ids_list.append(grid_indices.reshape(3, -1) + st_idx) + st = offset + grid_t * grid_h * grid_w - thinker_config = self.config - audio_token_id = thinker_config.audio_token_index - image_token_id = thinker_config.image_token_index - video_token_id = thinker_config.video_token_index - audio_start_token_id = thinker_config.audio_start_token_id - audio_end_token_id = thinker_config.audio_end_token_id - vision_start_token_id = thinker_config.vision_start_token_id - vision_end_token_id = thinker_config.vision_end_token_id - seconds_per_chunk = thinker_config.seconds_per_chunk - spatial_merge_size = thinker_config.vision_config.spatial_merge_size - tokens_per_second = getattr(thinker_config.vision_config, "tokens_per_second", 25) + elif modality == "video": + grid_t = data["grid_t"] + grid_h = data["grid_h"] + grid_w = data["grid_w"] + t_factor = data["t_factor"] - src_item = input_tokens - audio_seqlens = audio_feature_lengths - if not second_per_grid_ts: - second_per_grid_ts = [1] * video_grid_thw.shape[0] - audio_idx = 0 - video_idx = 0 - image_idx = 0 - new_src_item: list[int] = [] - llm_pos_ids_list: list[torch.Tensor] = [] + if not data["use_audio_in_video"]: + grid_indices = np.indices((grid_t, grid_h, grid_w)) + if t_factor != 1.0: + grid_indices[0] = (grid_indices[0] * t_factor).astype(np.int64) + llm_pos_ids_list.append(grid_indices.reshape(3, -1) + st_idx) + st = offset + grid_t * grid_h * grid_w + else: + pos_ids, token_count = self._compute_interleaved_positions(st_idx, data) + llm_pos_ids_list.append(pos_ids) + st = offset + token_count - idx = 0 - while idx < len(src_item): - new_src_item_len = len(new_src_item) - start_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0 - if src_item[idx] not in [audio_token_id, video_token_id, image_token_id]: - if use_audio_in_video and idx > 0: - if src_item[idx] == vision_end_token_id and src_item[idx - 1] == audio_end_token_id: - # processing the <|audio_eos|> before <|vision_eos|> - start_idx -= 1 - elif src_item[idx] == audio_start_token_id and src_item[idx - 1] == vision_start_token_id: - # processing the <|audio_bos|> after <|vision_eos|> - start_idx -= 1 - new_src_item.append(src_item[idx]) - llm_pos_ids = torch.tensor([start_idx], dtype=torch.long).expand(3, -1) - llm_pos_ids_list.append(llm_pos_ids) - elif src_item[idx] == audio_token_id: - assert audio_seqlens is not None - audio_seqlen = audio_seqlens[audio_idx] - place_num = ((audio_seqlen - 1) // 2 + 1 - 2) // 2 + 1 - new_src_item.extend([audio_token_id] * place_num) - llm_pos_ids = torch.arange(place_num).expand(3, -1) + start_idx - llm_pos_ids_list.append(llm_pos_ids) - audio_idx += 1 - elif src_item[idx] == image_token_id: - grid_t = image_grid_thw[image_idx][0] - grid_hs = image_grid_thw[:, 1] - grid_ws = image_grid_thw[:, 2] - t_index = (torch.arange(grid_t) * 1 * tokens_per_second).long() - llm_pos_ids = get_llm_pos_ids_for_vision( - start_idx, image_idx, spatial_merge_size, t_index, grid_hs, grid_ws - ) - llm_pos_ids_list.append(llm_pos_ids) - vision_seqlen = image_grid_thw[image_idx].prod() // (spatial_merge_size**2) - new_src_item.extend([image_token_id] * vision_seqlen) - image_idx += 1 - elif src_item[idx] == video_token_id and not use_audio_in_video: - grid_t = video_grid_thw[video_idx][0] - grid_hs = video_grid_thw[:, 1] - grid_ws = video_grid_thw[:, 2] - t_index = (torch.arange(grid_t) * second_per_grid_ts[video_idx] * tokens_per_second).long() - llm_pos_ids = get_llm_pos_ids_for_vision( - start_idx, video_idx, spatial_merge_size, t_index, grid_hs, grid_ws - ) - llm_pos_ids_list.append(llm_pos_ids) - vision_seqlen = video_grid_thw[video_idx].prod() // (spatial_merge_size**2) - new_src_item.extend([video_token_id] * vision_seqlen) - video_idx += 1 - else: - # read audio from video - assert audio_seqlens is not None - audio_seqlen = audio_seqlens[audio_idx] - vision_seqlen = video_grid_thw[video_idx].prod() // (spatial_merge_size**2) - grid_t = video_grid_thw[video_idx][0] - grid_h = video_grid_thw[video_idx][1] - grid_w = video_grid_thw[video_idx][2] - grid_hs = video_grid_thw[:, 1] - grid_ws = video_grid_thw[:, 2] - t_ntoken_per_chunk = int(tokens_per_second * seconds_per_chunk) - t_index = (torch.arange(grid_t) * second_per_grid_ts[video_idx] * tokens_per_second).long() - t_index_split_chunk = split_list_into_ranges(t_index, t_ntoken_per_chunk) - place_num = (((audio_seqlen - 1) // 2 + 1 - 2) // 2 + 1) + 2 - pure_audio_len = place_num - 2 - added_audio_len = 0 - audio_llm_pos_ids_list: list[torch.Tensor] = [] - for t_chunk in t_index_split_chunk: - vision_ntoken_per_chunk = len(t_chunk) * grid_h * grid_w // (spatial_merge_size**2) - new_src_item.extend([video_token_id] * vision_ntoken_per_chunk) - vision_llm_pos_ids_list = get_llm_pos_ids_for_vision( - start_idx, - video_idx, - spatial_merge_size, - t_chunk, - grid_hs, - grid_ws, - ).split(1, dim=1) - llm_pos_ids_list.extend(vision_llm_pos_ids_list) - new_src_item.extend(min(t_ntoken_per_chunk, pure_audio_len - added_audio_len) * [audio_token_id]) - audio_start_idx = ( - start_idx if len(audio_llm_pos_ids_list) == 0 else audio_llm_pos_ids_list[-1][0].item() + 1 - ) - if min(t_ntoken_per_chunk, pure_audio_len - added_audio_len) > 0: - audio_llm_pos_ids_list = ( - torch.arange(min(t_ntoken_per_chunk, pure_audio_len - added_audio_len)).expand(3, -1) - + audio_start_idx - ).split(1, dim=1) - else: - audio_llm_pos_ids_list = [] - added_audio_len += min(t_ntoken_per_chunk, pure_audio_len - added_audio_len) - llm_pos_ids_list.extend(audio_llm_pos_ids_list) - if added_audio_len < pure_audio_len: - new_src_item.extend((pure_audio_len - added_audio_len) * [audio_token_id]) - audio_llm_pos_ids_list = ( - torch.arange(pure_audio_len - added_audio_len).expand(3, -1) + llm_pos_ids_list[-1].max() + 1 - ).split(1, dim=1) - llm_pos_ids_list.extend(audio_llm_pos_ids_list) - audio_idx += 1 - video_idx += 1 - # move to the next token - idx += len(new_src_item) - new_src_item_len + if st < len(input_tokens): + st_idx = int(llm_pos_ids_list[-1].max()) + 1 if llm_pos_ids_list else 0 + text_len = len(input_tokens) - st + llm_pos_ids_list.append(np.broadcast_to(np.arange(text_len), (3, text_len)) + st_idx) - llm_positions = torch.cat(llm_pos_ids_list, dim=1) - mrope_position_delta = torch.cat(llm_pos_ids_list, dim=1).max() + 1 - len(src_item) + llm_positions = np.concatenate(llm_pos_ids_list, axis=1).reshape(3, -1) + mrope_position_delta = int(llm_positions.max()) + 1 - len(input_tokens) - return llm_positions, mrope_position_delta + return torch.from_numpy(llm_positions), mrope_position_delta def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings: mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs) @@ -629,14 +649,12 @@ class Qwen2_5OmniThinkerForConditionalGeneration( # Check for audio-in-video: interleaved video and audio tokens # in the multimodal region. Only use the interleaved path when # needed; otherwise fall back to the default parent implementation. - # vLLM's _gather_mm_embeddings builds is_multimodal on CPU for merge - # indexing; bitwise ops with input_ids require the same device. - is_mm_device = is_multimodal.to(device=input_ids.device, non_blocking=True) video_token_id = self.config.video_token_index audio_token_id = self.config.audio_token_index - is_video = is_mm_device & (input_ids == video_token_id) - is_audio = is_mm_device & (input_ids == audio_token_id) + input_ids_cpu = input_ids.cpu() + is_video = is_multimodal & (input_ids_cpu == video_token_id) + is_audio = is_multimodal & (input_ids_cpu == audio_token_id) num_video = is_video.sum().item() num_audio = is_audio.sum().item() @@ -652,7 +670,7 @@ class Qwen2_5OmniThinkerForConditionalGeneration( multimodal_embeddings, is_video, is_audio, - is_mm_device, + is_multimodal, num_video, num_audio, ) 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 17be417f3..44aaaf4d3 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 @@ -758,17 +758,11 @@ class Qwen3OmniMoeThinkerMultiModalProcessor( use_audio_in_video = False if "video" in mm_kwargs: - non_none_items = [item for item in mm_kwargs["video"] if item is not None] - if non_none_items: - # Normal case: at least one non-cached item, read flag directly - use_audio_in_video = any(item["use_audio_in_video"].data for item in non_none_items) - elif "audio" in mm_prompt_updates: - # All video items are from cache (None); infer from prompt: - # use_audio_in_video=True means the prompt has no <|audio_pad|> - # placeholder (audio is embedded in video tokens instead) - tokenizer = self.info.get_tokenizer() - audio_pad_id = tokenizer.convert_tokens_to_ids("<|audio_pad|>") - use_audio_in_video = audio_pad_id not in prompt_ids + for item in mm_kwargs["video"]: + if item and item["use_audio_in_video"].data: + use_audio_in_video = True + else: + use_audio_in_video = False # for mutilmodality cache if any(item is None for item in mm_kwargs["video"]): video_token_id = self.info.get_hf_config().video_token_id From 72cc496fb67c70399d2c205cac6536449975cce2 Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Mon, 8 Jun 2026 16:41:33 +0800 Subject: [PATCH 24/75] [Doc] Add precheck-pr Claude Code skill (#4216) Signed-off-by: hsliu Co-authored-by: Claude Opus 4.7 --- .claude/skills/precheck-pr/SKILL.md | 97 +++++++++++++ .../precheck-pr/references/checklists.md | 135 ++++++++++++++++++ .claude/skills/readme.md | 5 + .github/PULL_REQUEST_TEMPLATE.md | 5 +- docs/contributing/README.md | 14 ++ 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/precheck-pr/SKILL.md create mode 100644 .claude/skills/precheck-pr/references/checklists.md diff --git a/.claude/skills/precheck-pr/SKILL.md b/.claude/skills/precheck-pr/SKILL.md new file mode 100644 index 000000000..ab6034a48 --- /dev/null +++ b/.claude/skills/precheck-pr/SKILL.md @@ -0,0 +1,97 @@ +--- +name: precheck-pr +description: Self-check your branch before creating a PR — catch dead code, verify accuracy/perf claims, validate PR title format, and confirm merge readiness. Use when the user says "precheck", "self review", "pre-submit check", or "check my PR before I open it." Never posts to GitHub. +--- + +# PR Pre-Check + +Self-review your branch before creating a PR against `vllm-project/vllm-omni`. Two modes: **quick** catches showstoppers, **full** does a thorough maintainer-grade review. Never posts to GitHub; the report is for the contributor's terminal only. + +## Mode Selection + +| Mode | When | Time | +|------|------|------| +| **Quick** | About to push, final sanity check | ~3 min | +| **Full** | Ready for review, want maintainer-level scan | ~10 min | + +Default to quick if unsure. Run full before marking a PR "ready for review." + +## Workflow + +### Step 1: Detect Base Branch + +```bash +BASE_SHA=$(git merge-base HEAD origin/main 2>/dev/null \ + || git merge-base HEAD main 2>/dev/null \ + || echo origin/main) +echo "diffing against ${BASE_SHA}" +git diff --name-only ${BASE_SHA}...HEAD +``` + +### Step 2: Validate PR Title + +Check the most recent commit message (or branch name if no commit yet) against the project convention. Valid prefixes: + +| Prefix | Applies to | +|--------|-----------| +| `[Bugfix]` | Bug fixes | +| `[CI/Build]` | Build or CI improvements | +| `[Doc]` | Documentation changes | +| `[Model]` | New/improved models (include model name) | +| `[Frontend]` | Frontend changes (API server, OmniLLM class, etc.) | +| `[Kernel]` | CUDA/kernel changes | +| `[Core]` | Core logic changes (OmniProcessor, OmniARScheduler, etc.) | +| `[Hardware][Vendor]` | Hardware-specific (e.g., `[Hardware][Ascend]`) | +| `[Misc]` | Other changes (use sparingly) | + +✗ if: missing prefix, wrong case (`[bugfix]`), or WIP/Draft in title. +⚠ if: `[Model]` prefix without the model identifier (e.g., `[Model] Add new model` — should be `[Model] Add ...`). + +### Step 3: Categorize the PR + +| Diff contains | PR type | +|---------------|---------| +| New files under `vllm_omni/model_executor/models//` | **New Model** | +| Changes to `vllm_omni/diffusion/` | **Diffusion Model** | +| `[Bugfix]` prefix or single-file fix | **Bug Fix** | +| Perf/benchmark/throughput claims in commit msg or diff | **Performance** | +| Everything else | **General** | + +If multiple rows apply (e.g., a diffusion model is also a new model), union the checklists. + +### Step 4: Run Checklist + +Ask: "Quick mode or full mode?" Then walk the checklist for the detected PR type from [references/checklists.md](references/checklists.md). Each item produces ✓, ✗, or ⚠. + +### Step 5: Print Report + +``` +Pre-check report for + + Mode: quick | full + Type: + + Dimension Result + ───────────────── ────── + PR title format ✓ + PR desc integrity ✓ + Registry/config ✓ + Dead code ⚠ 2 warnings + Accuracy ✓ + Benchmark ✗ missing software versions + + Verdict: 1 blocking | 2 warnings | recommend fixing ✗ before PR +``` + +**Severity:** + +| Mark | Meaning | +|------|---------| +| ✗ | Blocking — fix before opening PR | +| ⚠ | Warning — consider fixing | +| ✓ | Pass | +| — | Skipped (not applicable) | + +### Stop Here + +Do not post comments, open PRs, or modify files. The report is for the contributor's terminal only. diff --git a/.claude/skills/precheck-pr/references/checklists.md b/.claude/skills/precheck-pr/references/checklists.md new file mode 100644 index 000000000..5b9472d96 --- /dev/null +++ b/.claude/skills/precheck-pr/references/checklists.md @@ -0,0 +1,135 @@ +# Pre-Check Checklists + +Each item is a concrete check to run against the diff. ✗ = fix before PR. ⚠ = consider fixing. + +--- + +## All PRs: Title Format (check first) + +PR title must follow the project convention documented in `docs/contributing/README.md`: + +| Prefix | Applies to | +|--------|-----------| +| `[Bugfix]` | Bug fixes | +| `[CI/Build]` | Build or CI improvements | +| `[Doc]` | Documentation changes | +| `[Model]` | New/improved models (include model name) | +| `[Frontend]` | Frontend changes (API server, OmniLLM class, etc.) | +| `[Kernel]` | CUDA/kernel changes | +| `[Core]` | Core logic changes (OmniProcessor, OmniARScheduler, etc.) | +| `[Hardware][Vendor]` | Hardware-specific (e.g., `[Hardware][Ascend]`) | +| `[Misc]` | Other changes (use sparingly) | + +**Examples:** +``` +✓ [Model] Add MiniCPM-o 4.5 support +✓ [Bugfix] Fix Ovis image text encoder dtype +✓ [Core] Qwen3-Omni performance optimization +✓ [Hardware][Ascend] Fix VoxCPM2 on Ascend +✗ Fix Ovis image text encoder dtype ← missing prefix +✗ [bugfix] Fix Ovis image text encoder ← wrong case +✗ [WIP] Add model ← WIP in title +``` + +- [ ] **Title has valid prefix** — ✗ if missing prefix, wrong case, or WIP/Draft in title. For `[Model]` PRs, the title MUST also include the model identifier (e.g., `[Model] Add ...`). ⚠ if missing the model name. + +--- + +## Bug Fix PRs + +### Quick + +- [ ] **PR body has 4 sections:** what broke (error message), repro steps, root cause, fix description +- [ ] **Fix is minimal:** diff touches only files directly related to the bug. No unrelated refactors or style changes. +- [ ] **Repro is runnable:** the PR body includes a copy-paste command that reproduces the bug +- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts + +### Full (adds) + +- [ ] **Regression test exists:** `git diff --name-only` includes at least one `tests/` file +- [ ] **Regression test reproduces the original error:** the test would fail on main, pass on this branch +- [ ] **Fix matches root cause exactly:** no "fix the symptom + something else" — if the root cause is dtype, the fix is dtype, not dtype + reformatting +- [ ] **No silent failure risk:** no bare `except: pass`, no `try: ... except: return None`, no empty fallback added +- [ ] **Upstream pattern match:** the fix follows the same pattern as existing code for similar cases (grep for analogous `from_pretrained` calls, etc.) +- [ ] **Environment documented:** torch/transformers/vllm versions listed if the bug is version-dependent + +--- + +## Performance PRs + +### Quick + +- [ ] **Before/after numbers in PR body:** at minimum, one metric (RTF, RPS, latency, VRAM) with main vs PR values +- [ ] **Hardware specified:** GPU model, count, VRAM. "L4" or "H20" is sufficient. "tested on GPU" is not. +- [ ] **Concurrency scaling:** if the optimization claims better throughput, numbers at 2+ concurrency levels +- [ ] **No unexplained regressions:** if any metric goes down at any concurrency level, explain why +- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts + +### Full (adds) + +- [ ] **Software versions:** torch, CUDA, vllm, vllm-omni versions in PR body +- [ ] **Warmup stated:** "1 warmup + 3 measured" or equivalent +- [ ] **Stddev or range:** not just a single best run. "average of 2 rounds" is minimum. +- [ ] **VRAM measured:** peak GPU memory before and after +- [ ] **Benchmark script checked in:** under `tests/` or `examples/`, runnable by reviewer +- [ ] **Exact command line in PR:** not pseudocode, not "run the benchmark" +- [ ] **Pytest summary included:** `36 passed, 3 skipped, 17 warnings in 2897.66s` — proves the whole suite passes +- [ ] **Config changes documented:** if `max_num_seqs`, `gpu_memory_utilization`, or deploy YAML changed, old and new values stated with reasoning + +--- + +## New Model PRs + +### Quick + +- [ ] **PR body file list matches `git diff --name-only`:** every file path in the description exists in the diff +- [ ] **Registry entries match claimed count:** count `_OMNI_MODELS` or `_DIFFUSION_MODELS` entries added vs claimed in body +- [ ] **Pipeline config `custom_process_input_func` paths resolve:** each referenced module.function exists +- [ ] **`__init__.py` exports match registry:** each registered class appears in `__init__.py` and `__all__` +- [ ] **Shell script modes match README:** `run_curl.sh` supports every mode the README documents +- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts + +### Full (adds) + +- [ ] **Dead code scan (5 patterns):** + 1. Dead `forward()` — defined but never called through the pipeline path + 2. Dead factory/builder functions — instantiated nowhere + 3. Dead wrappers — thin wrappers with no callers + 4. Dead branch guards — `if version > X` where X is always satisfied + 5. Unused parameters — `__init__` args never accessed +- [ ] **Copy-paste detection:** no string constant defined in 3+ files, no cross-module validation duplication, no near-identical shape coercion fns +- [ ] **Import hygiene:** no re-export-only imports, no module-level side effects without docs, no `import os` in function body when already at top. Function-body imports are acceptable when the import is only reachable from a closure or factory return value (i.e., `import` would otherwise execute at module load even though the user may never trigger that path). Either lift to top-of-file or annotate with `# noqa: PLC0415`. +- [ ] **Accuracy:** at least one test compares output against reference; audio = valid WAV at correct sample rate; determinism verified if seed claimed. ⚠ if missing and not explicitly deferred to a follow-up PR or RFC section; — if explicitly deferred (the deferral target SHOULD be linked in the PR body). +- [ ] **Performance:** hardware, software versions, warmup, and metrics (RTF, VRAM, RPS, latency) stated. ⚠ if missing and not explicitly deferred; — if explicitly deferred. +- [ ] **Benchmark settings:** model config (TP, PP, max_model_len, enforce_eager, quant), runtime config (batch, input/output spec), environment versions +- [ ] **Benchmark script checked in + exact command line + pytest summary** + +--- + +## Diffusion Model PRs + +Diffusion models live under `vllm_omni/diffusion/`. In addition to the New Model checklist above, verify: + +- [ ] **Transformer adapter follows contract:** inherits from `nn.Module`, implements `load_weights()`, uses `vllm_omni.diffusion.attention.layer.Attention` +- [ ] **Pipeline has both offline and online paths:** `forward()` for batch generation, streaming path for online serving +- [ ] **Cache-DiT integration:** at least one acceleration (TeaCache, FBCache, etc.) is wired up. ✗ for new **top-level** pipelines. For **subclass** pipelines that inherit `__call__` from a parent where Cache-DiT is already absent, downgrade to ⚠ and recommend filing a follow-up issue against the parent pipeline family. +- [ ] **Parallelism config:** TP/SP/USP/CFG-Parallel options exposed in pipeline config +- [ ] **Docs table updated:** model added to `docs/models/supported_models.md` with correct metadata +- [ ] **E2E test exists:** at least one test exercises the full generate path + +--- + +## General PRs + +### Quick + +- [ ] **PR body matches diff:** description doesn't claim changes not in the diff +- [ ] **Branch is rebased:** `git merge-base HEAD origin/main` is recent, no merge conflicts +- [ ] **CI gates passing:** DCO, pre-commit, build — check `gh pr view --json statusCheckRollup` if a PR already exists + +### Full (adds) + +- [ ] **No new dead code in changed files:** scan for unreachable paths, unused imports +- [ ] **Test coverage:** if core code changed (`engine/`, `stages/`, `connectors/`), tests are added or existing tests cover the path +- [ ] **Import hygiene:** no redundant imports added +- [ ] **No unrelated changes:** diff scope matches PR description diff --git a/.claude/skills/readme.md b/.claude/skills/readme.md index 44a4cd11d..36b0b8ed3 100644 --- a/.claude/skills/readme.md +++ b/.claude/skills/readme.md @@ -25,7 +25,12 @@ include: - `add-tts-model`: covers integration of new TTS models and related serving workflows - `generate-release-note`: helps prepare release notes for repository changes +- `precheck-pr`: self-check a branch before creating a PR — validates PR title + format, catches dead code, verifies accuracy/perf claims, and confirms merge + readiness - `review-pr`: provides a structured workflow for reviewing pull requests +- `vllm-omni-npu-model-runner-upgrade`: upgrades NPU model runners to align with the + latest vllm-ascend NPUModelRunner ## Maintenance Guidelines diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1b4762794..1f0ec2d50 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,6 @@ +**Before submitting:** run the precheck-pr skill with code agent for a self-check against project conventions — catches dead code, missing benchmarks, and title format issues. + PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTTOM) HAVE BEEN CONSIDERED. ## Purpose @@ -18,4 +20,5 @@ PLEASE FILL IN THE PR DESCRIPTION HERE ENSURING ALL CHECKLIST ITEMS (AT THE BOTT - [ ] (Optional) Release notes update. If your change is user-facing, please update the release notes draft. -**BEFORE SUBMITTING, PLEASE READ ** (anything written below this line will be removed by GitHub Actions) +**BEFORE SUBMITTING,** please read +(anything written below this line will be removed by GitHub Actions) diff --git a/docs/contributing/README.md b/docs/contributing/README.md index 8a5bcfff0..4e6e5b4d2 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -116,6 +116,20 @@ Only specific types of PRs will be reviewed. The PR title is prefixed appropriat !!! note If the PR spans more than one category, please include all relevant prefixes. +### Pre-Check Before Submitting + +Before submitting a PR, run the precheck-pr skill with code agent for a self-review against project conventions: + +``` +/precheck-pr +``` + +The skill offers two modes: +- **Quick (~3 min):** catches showstoppers — PR title format, missing benchmark claims, rebase status +- **Full (~10 min):** thorough maintainer-grade review — dead code scan, copy-paste detection, import hygiene + +The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Model, and General. Each type has a tailored checklist that validates evidence quality (repro steps, A/B benchmarks, registry entries, etc.). See the skill in `.claude/skills/precheck-pr/` for the full checklist. + ### Local Test Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_guide.md) for running the test cases. From 13f9ecaef8dacb2eedf78dd6985377830eff9a61 Mon Sep 17 00:00:00 2001 From: Mu GuanLin <39453182+mglyn@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:15:06 +0800 Subject: [PATCH 25/75] [Perf] Keep LTX2.3 auxiliary modules resident by default (#4144) Signed-off-by: mglyn <1203789601@qq.com> --- docs/user_guide/diffusion_features.md | 2 +- .../models/ltx2/test_ltx2_3_pipeline.py | 25 ++++++++++ .../diffusion/models/ltx2/pipeline_ltx2_3.py | 50 ++++++++++--------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 945f83caa..d4b0c9177 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -148,7 +148,7 @@ The following tables show which models support each feature: | **Wan2.2-S2V** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | | **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ | | **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py index 3930677d3..9943d092b 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py @@ -72,6 +72,31 @@ class TestPipelineIndependence: assert issubclass(LTX23Pipeline, CFGParallelMixin) + def test_ltx23_pipeline_declares_offload_components(self): + """LTX23Pipeline must expose LTX-2.3-specific modules to offload discovery.""" + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery + + pipe = object.__new__(LTX23Pipeline) + torch.nn.Module.__init__(pipe) + pipe.transformer = torch.nn.Linear(1, 1) + pipe.text_encoder = torch.nn.Linear(1, 1) + pipe.connectors = torch.nn.Linear(1, 1) + pipe.vae = torch.nn.Linear(1, 1) + pipe.audio_vae = torch.nn.Linear(1, 1) + pipe.vocoder = torch.nn.Linear(1, 1) + + modules = ModuleDiscovery.discover(pipe) + + assert LTX23Pipeline._dit_modules == ["transformer"] + assert LTX23Pipeline._encoder_modules == ["text_encoder", "connectors"] + assert LTX23Pipeline._vae_modules == ["vae", "audio_vae"] + assert LTX23Pipeline._resident_modules == ["vocoder"] + assert modules.dit_names == ["transformer"] + assert modules.encoder_names == ["text_encoder", "connectors"] + assert modules.resident_names == ["vocoder"] + assert len(modules.vaes) == 2 + class TestCFGParallelHelpers: """Test LTX-2.3 CFG helper math without loading model weights.""" diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py index d28ff69d9..caa40c93f 100644 --- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py +++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py @@ -10,7 +10,6 @@ This pipeline does NOT inherit from LTX2Pipeline because: versus LTX-2's per_layer_masked_mean_norm + shared projection path - LTX-2.3 uses a BWE vocoder outputting 48kHz audio (not 16kHz) - LTX-2.3 transformer requires the sigma parameter for prompt modulation -- CPU offloading is required for the 22B transformer (~44GB VRAM) """ from __future__ import annotations @@ -20,7 +19,7 @@ import json import os from collections.abc import Iterable from contextlib import nullcontext -from typing import Any +from typing import Any, ClassVar import numpy as np import torch @@ -46,7 +45,9 @@ from vllm_omni.diffusion.distributed.parallel_state import ( 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.models.interface import SupportsComponentDiscovery from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin +from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery from vllm_omni.diffusion.request import OmniDiffusionRequest from .pipeline_ltx2 import ( @@ -112,7 +113,7 @@ def get_ltx2_post_process_func(od_config: OmniDiffusionConfig): return post_process_func -class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): +class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin, SupportsComponentDiscovery): """Fully independent LTX-2.3 pipeline. Key differences from LTX2Pipeline: @@ -120,11 +121,14 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): - Connectors: uses padding_side API (not additive_mask) - Vocoder: uses LTX2VocoderWithBWE (48kHz output) - Transformer: passes sigma for prompt_adaln - - CPU offloading: text encoder, connectors, VAE, vocoder stay on CPU """ # Audio is diffused jointly with video; warmup must size audio tokens. dummy_run_num_frames = 2 + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder", "connectors"] + _vae_modules: ClassVar[list[str]] = ["vae", "audio_vae"] + _resident_modules: ClassVar[list[str]] = ["vocoder"] def __init__( self, @@ -166,7 +170,7 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): # --- Tokenizer (lightweight, stays wherever) --- self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only) - # --- Text encoder: load on CPU, move to GPU only during encoding --- + # --- Text encoder --- with torch.device("cpu"): self.text_encoder = from_pretrained_with_prefetch( Gemma3ForConditionalGeneration.from_pretrained, @@ -177,7 +181,7 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): torch_dtype=dtype, ) - # --- Connectors: CPU (LTX-2.3 connectors include caption projection) --- + # --- Connectors (LTX-2.3 connectors include caption projection) --- self.connectors = from_pretrained_with_prefetch( LTX2TextConnectors.from_pretrained, model, @@ -187,7 +191,7 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): torch_dtype=dtype, ) - # --- VAE, Audio VAE: CPU --- + # --- VAE, Audio VAE --- self.vae = from_pretrained_with_prefetch( AutoencoderKLLTX2Video.from_pretrained, model, @@ -220,6 +224,7 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): transformer_config = load_transformer_config(model, "transformer", local_files_only) quant_config = getattr(self.od_config, "quantization_config", None) self.transformer = create_transformer_from_config(transformer_config, quant_config=quant_config) + self._place_aux_components() # --- Scheduler --- self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained( @@ -261,6 +266,20 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): self._num_timesteps = None self._current_timestep = None + def _place_aux_components(self) -> None: + parallel_config = getattr(self.od_config, "parallel_config", None) + use_managed_placement = bool( + getattr(self.od_config, "enable_cpu_offload", False) + or getattr(self.od_config, "enable_layerwise_offload", False) + or getattr(parallel_config, "use_hsdp", False) + ) + if use_managed_placement: + return + + modules = ModuleDiscovery.discover(self) + for module in (*modules.encoders, *modules.vaes, *modules.resident_modules): + module.to(self.device) + # ------------------------------------------------------------------ # Text Encoding (LTX-2.3 specific) # ------------------------------------------------------------------ @@ -303,16 +322,11 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): text_input_ids = text_inputs.input_ids.to(device) prompt_attention_mask = text_inputs.attention_mask.to(device) - # Move text encoder to GPU for encoding - self.text_encoder.to(device) text_encoder_outputs = self.text_encoder( input_ids=text_input_ids, attention_mask=prompt_attention_mask, output_hidden_states=True, ) - # Move text encoder back to CPU immediately - self.text_encoder.to("cpu") - torch.accelerator.empty_cache() hidden_states = text_encoder_outputs.hidden_states @@ -958,14 +972,10 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): prompt_embeds = torch.cat([negative_prompt_embeds, prompt_embeds], dim=0) prompt_attention_mask = torch.cat([negative_prompt_attention_mask, prompt_attention_mask], dim=0) - self.connectors.to(device) tokenizer_padding_side = getattr(self.tokenizer, "padding_side", "left") connector_prompt_embeds, connector_audio_prompt_embeds, connector_attention_mask = self.connectors( prompt_embeds, prompt_attention_mask, padding_side=tokenizer_padding_side ) - self.connectors.to("cpu") - if torch.cuda.is_available(): - torch.accelerator.empty_cache() positive_connector_prompt_embeds = connector_prompt_embeds positive_connector_audio_prompt_embeds = connector_audio_prompt_embeds @@ -1241,22 +1251,14 @@ class LTX23Pipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): ] latents = (1 - decode_noise_scale_t) * latents + decode_noise_scale_t * noise - # Move VAE, audio_vae, vocoder to GPU for decoding - self.vae.to(device) latents = latents.to(self.vae.dtype) video = self.vae.decode(latents, timestep_decode, return_dict=False)[0] video = self.video_processor.postprocess_video(video, output_type=output_type) - self.vae.to("cpu") - self.audio_vae.to(device) audio_latents = audio_latents.to(self.audio_vae.dtype) generated_mel_spectrograms = self.audio_vae.decode(audio_latents, return_dict=False)[0] - self.audio_vae.to("cpu") - self.vocoder.to(device) audio = self.vocoder(generated_mel_spectrograms) - self.vocoder.to("cpu") - torch.accelerator.empty_cache() return DiffusionOutput(output=(video, audio)) From 9c9d4ed4a83e51856878de7213e28b731a8e9a02 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Tue, 9 Jun 2026 01:33:20 +0800 Subject: [PATCH 26/75] [Fix][HiggsAudioV3] Fix ramp-down off-by-one crash and buffer state bugs in Stage0 talker (#4219) Signed-off-by: Sy03 <1370724210@qq.com> --- .../higgs_audio_v3/test_higgs_audio_v3.py | 303 +++++++++++++++++- .../higgs_audio_v3/higgs_audio_v3_talker.py | 36 ++- .../stage_input_processors/higgs_audio_v3.py | 36 ++- 3 files changed, 360 insertions(+), 15 deletions(-) diff --git a/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py index d73f0c6a5..627e22ffb 100644 --- a/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py +++ b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py @@ -410,6 +410,8 @@ class TestAudioFeedback: model = type("M", (), {"embed_tokens": lambda self, ids: torch.zeros(ids.shape[0], 16)})() t = FakeTalker() + t._decode_last_codes = torch.zeros(256, 8, dtype=torch.long) + t._decode_has_codes = torch.zeros(256, dtype=torch.bool) t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t) return t @@ -427,6 +429,8 @@ class TestAudioFeedback: t = self._make_feedback_talker() audio_id = t._audio_continuation_id t._audio_state[1] = {"last_codes": torch.zeros(8, dtype=torch.long)} + t._decode_last_codes[1] = torch.zeros(8, dtype=torch.long) + t._decode_has_codes[1] = True input_ids = torch.tensor([1, audio_id, 3]) hidden = torch.zeros(3, 16) result = t._apply_audio_feedback(hidden, input_ids) @@ -440,7 +444,7 @@ class TestAudioFeedback: """Audio position without state should not be replaced.""" t = self._make_feedback_talker() audio_id = t._audio_continuation_id - # No state for row 0 + # No state for row 0 — _decode_has_codes[0] defaults to False input_ids = torch.tensor([audio_id]) hidden = torch.zeros(1, 16) result = t._apply_audio_feedback(hidden, input_ids) @@ -908,3 +912,300 @@ class TestPromptBuilder: # Should not contain ref_audio or ref_text token IDs assert 151703 not in ids # <|ref_audio|> assert 151704 not in ids # <|ref_text|> + + +# ---- Regression: ramp-down done-before-emit off-by-one ---- + + +def _simulate_delay_state_machine( + sampled_codes_per_step: list[torch.Tensor], +) -> list[torch.Tensor]: + """Reproduce the delay-pattern state machine from sample(). + + Feeds ``sampled_codes_per_step`` (each [Q]) through the exact same + logic as HiggsAudioV3TalkerForConditionalGeneration.sample() and + returns the list of emitted frames (non -1 codes). + """ + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import ( + BOC_ID, + EOC_ID, + ) + + num_codebooks = 8 + bos = BOC_ID + eos_stream = EOC_ID + + state: dict = { + "num_delay": 0, + "num_remaining_delays": None, + "audio_out_ids": None, + "last_codes": torch.full((num_codebooks,), bos, dtype=torch.long), + "should_terminate": False, + } + + emitted: list[torch.Tensor] = [] + for step_codes in sampled_codes_per_step: + if state["should_terminate"]: + break + + num_delay = state["num_delay"] + num_remaining_delays = state["num_remaining_delays"] + this_codes = step_codes.clone() + + if num_delay + 1 < num_codebooks: + this_codes[num_delay + 1 :] = bos + num_delay += 1 + + if num_remaining_delays is not None: + this_codes[: num_codebooks - num_remaining_delays] = eos_stream + num_remaining_delays -= 1 + else: + eos_positions = (this_codes == eos_stream).nonzero(as_tuple=False).reshape(-1) + if eos_positions.numel() > 0: + last_eos_idx = int(eos_positions[-1].item()) + this_codes[: last_eos_idx + 1] = eos_stream + num_remaining_delays = num_codebooks - last_eos_idx - 1 + + if num_remaining_delays is not None and num_remaining_delays <= 0: + state["num_delay"] = 0 + state["num_remaining_delays"] = None + state["should_terminate"] = True + continue + + state["num_delay"] = num_delay + state["num_remaining_delays"] = num_remaining_delays + state["last_codes"] = this_codes.clone() + emitted.append(this_codes) + + return emitted + + +class TestRampDownOffByOne: + """Regression tests for the done-before-emit off-by-one bug. + + When EOC fires early, the ramp-down must emit enough frames so that + Stage1's _revert_delay_pattern receives T >= Q = 8. + """ + + def test_eoc_at_step0_terminates(self): + """EOC on cb0 at step 0 terminates; T < Q is handled by Stage1 defensive try/except.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + + Q = 8 + steps = [] + for s in range(20): + codes = torch.randint(0, 1024, (Q,)) + if s == 0: + codes[0] = EOC_ID + steps.append(codes) + + emitted = _simulate_delay_state_machine(steps) + assert len(emitted) == Q - 1, f"EOC at step 0: expected {Q - 1} emitted frames, got {len(emitted)}" + + def test_eoc_at_step1_emits_enough_frames(self): + """EOC on cb0 at step 1 must produce >= Q emitted frames.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + + Q = 8 + steps = [] + for s in range(20): + codes = torch.randint(0, 1024, (Q,)) + if s == 1: + codes[0] = EOC_ID + steps.append(codes) + + emitted = _simulate_delay_state_machine(steps) + assert len(emitted) >= Q, f"EOC at step 1: expected >= {Q} emitted frames, got {len(emitted)}" + + def test_eoc_after_full_rampup_emits_enough(self): + """EOC after all codebooks are active produces T >> Q.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + + Q = 8 + eoc_step = 10 + steps = [] + for s in range(30): + codes = torch.randint(0, 1024, (Q,)) + if s == eoc_step: + codes[0] = EOC_ID + steps.append(codes) + + emitted = _simulate_delay_state_machine(steps) + assert len(emitted) >= Q, f"EOC at step {eoc_step}: expected >= {Q} emitted frames, got {len(emitted)}" + + def test_last_rampdown_frame_not_all_negative(self): + """The last emitted ramp-down frame must contain real codes, not -1.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + + Q = 8 + steps = [] + for s in range(20): + codes = torch.randint(0, 1024, (Q,)) + if s == 1: + codes[0] = EOC_ID + steps.append(codes) + + emitted = _simulate_delay_state_machine(steps) + last = emitted[-1] + assert (last >= 0).all(), f"Last ramp-down frame contains negative values: {last.tolist()}" + + def test_emitted_frames_revertible_by_stage1(self): + """Emitted frames from EOC-at-step-1 must be revertible by _revert_delay_pattern.""" + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_talker import EOC_ID + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _revert_delay_pattern, + ) + + Q = 8 + steps = [] + for s in range(20): + codes = torch.randint(0, 1024, (Q,)) + if s == 1: + codes[0] = EOC_ID + steps.append(codes) + + emitted = _simulate_delay_state_machine(steps) + # Stack to [T, Q], transpose to [Q, T] + codes_qt = torch.stack(emitted, dim=0).t().contiguous() + assert codes_qt.shape[0] == Q + assert codes_qt.shape[1] >= Q + + # Must not raise + reverted = _revert_delay_pattern(codes_qt) + assert reverted.shape[0] == Q + assert reverted.shape[1] == codes_qt.shape[1] - Q + 1 + + +class TestStage1DefensiveHandling: + """Stage1 must not crash the server when receiving T < Q frames.""" + + def test_revert_delay_pattern_raises_on_insufficient_frames(self): + """_revert_delay_pattern correctly raises ValueError for T < Q.""" + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + _revert_delay_pattern, + ) + + codes = torch.zeros(8, 7) # T=7 < Q=8 + with pytest.raises(ValueError, match="Not enough frames"): + _revert_delay_pattern(codes) + + def test_talker2code2wav_survives_insufficient_frames(self): + """talker2code2wav must not crash when audio_codes has T < Q frames.""" + from vllm_omni.model_executor.stage_input_processors.higgs_audio_v3 import ( + talker2code2wav, + ) + + # Build a fake talker output with only 7 frames (T=7 < Q=8) + class FakeOutput: + def __init__(self): + self.multimodal_output = { + "codes": {"audio": torch.zeros(7, 8, dtype=torch.long)}, + } + + class FakeTalkerOutput: + finished = True + outputs = [FakeOutput()] + + # Must not raise — should return gracefully + result = talker2code2wav([FakeTalkerOutput()]) + assert len(result) == 1 + # Result is an OmniTokensPrompt with empty prompt_token_ids + out = result[0] + token_ids = out.prompt_token_ids if hasattr(out, "prompt_token_ids") else out.get("prompt_token_ids", []) + assert token_ids == [] + + +# ---- Regression: buffer growth + prefill guard ---- + + +class TestBufferGrowthPreservesState: + """Bug #1: buffer growth in _apply_audio_feedback must not wipe existing state.""" + + def _make_feedback_talker_with_buffers(self): + from vllm_omni.model_executor.models.higgs_audio_v3 import higgs_audio_v3_talker as mod + + embed = mod.HiggsFusedMultiTextEmbedding(num_codebooks=8, vocab_size=1026, hidden_size=16) + torch.nn.init.ones_(embed.weight) + + class FakeTalker: + num_codebooks = 8 + codebook_size = 1026 + multimodal_embedding = embed + + t = FakeTalker() + t._decode_last_codes = torch.zeros(4, 8, dtype=torch.long) + t._decode_has_codes = torch.zeros(4, dtype=torch.bool) + t._apply_audio_feedback = mod.HiggsAudioV3TalkerForConditionalGeneration._apply_audio_feedback.__get__(t) + return t + + def test_buffer_growth_preserves_existing_codes(self): + """Growing the buffer must copy old _decode_last_codes data.""" + t = self._make_feedback_talker_with_buffers() + # Set state for slot 2 + t._decode_last_codes[2] = torch.tensor([100, 200, 300, 400, 500, 600, 700, 800]) + t._decode_has_codes[2] = True + + # Trigger growth by calling with bs=8 > current capacity 4 + hidden = torch.randn(8, 16) + t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long)) + + # After growth, slot 2 data must still be present + assert t._decode_has_codes[2].item() is True + assert t._decode_last_codes[2, 0].item() == 100 + + def test_buffer_growth_new_slots_are_inactive(self): + """New slots from buffer growth must have has_codes=False.""" + t = self._make_feedback_talker_with_buffers() + t._decode_has_codes[1] = True + + hidden = torch.randn(8, 16) + t._apply_audio_feedback(hidden, torch.zeros(8, dtype=torch.long)) + + # Slots 4-7 (new) must be inactive + assert not t._decode_has_codes[4].item() + assert not t._decode_has_codes[7].item() + # Slot 1 (old) must still be active + assert t._decode_has_codes[1].item() + + +class TestPrefillGuard: + """Bug #2: _apply_audio_feedback must NOT run during prefill. + + The prefill detection uses attn_metadata.max_query_len (== 1 for decode, + > 1 for prefill). This correctly handles concurrent decode (N requests, + each contributing 1 token → numel=N but max_query_len=1). + """ + + @staticmethod + def _is_prefill_via_max_query_len(max_query_len: int | None, input_ids: torch.Tensor) -> bool: + """Mirror the actual detection logic in forward().""" + if max_query_len is not None: + return int(max_query_len) > 1 + return input_ids is not None and int(input_ids.numel()) > 1 + + def test_prefill_detected_via_max_query_len(self): + """Prefill: max_query_len > 1 → is_prefill=True.""" + input_ids = torch.tensor([1, 2, 3, 4, 5]) + assert self._is_prefill_via_max_query_len(5, input_ids) is True + + def test_single_decode_detected_via_max_query_len(self): + """Single-request decode: max_query_len=1 → is_prefill=False.""" + input_ids = torch.tensor([42]) + assert self._is_prefill_via_max_query_len(1, input_ids) is False + + def test_concurrent_decode_not_misclassified(self): + """Multi-request decode: numel=4 but max_query_len=1 → is_prefill=False. + + This is the regression caught by reviewer: with c=4 concurrent decode + requests, input_ids.numel()==4 but each request contributes exactly 1 + token, so max_query_len==1 and audio feedback must still apply. + """ + input_ids = torch.tensor([42, 43, 44, 45]) # 4 concurrent decode + assert self._is_prefill_via_max_query_len(1, input_ids) is False + + def test_fallback_when_max_query_len_unavailable(self): + """When attn_metadata is unavailable, fall back to numel heuristic.""" + prefill_ids = torch.tensor([1, 2, 3]) + assert self._is_prefill_via_max_query_len(None, prefill_ids) is True + decode_ids = torch.tensor([42]) + assert self._is_prefill_via_max_query_len(None, decode_ids) is False 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 4fc89341c..5e20245b0 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 @@ -250,7 +250,8 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): if input_ids is not None: self._last_step_input_ids = input_ids - # Stash query_start_loc for _apply_audio_mode_bias + # Stash query_start_loc and max_query_len for prefill detection + _max_query_len = None try: from vllm.forward_context import get_forward_context @@ -264,13 +265,19 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): self._last_step_query_start_loc = qsl.detach().clone() else: self._last_step_query_start_loc = None + _max_query_len = getattr(attn, "max_query_len", None) except Exception: self._last_step_query_start_loc = None - # Prefill-only operations: ref audio substitution and audio feedback - # require Python dict/list ops that break CUDA graph capture. - # Detect prefill (sequence length > batch size heuristic) vs decode. - is_prefill = input_ids is not None and inputs_embeds is None and int(input_ids.numel()) > 1 + # Detect prefill vs decode using attn_metadata.max_query_len. + # max_query_len == 1 means pure decode (even with N concurrent + # requests, each contributes exactly 1 token). numel > 1 is NOT + # reliable because decode with N>1 concurrent requests gives + # input_ids.numel() == N. + if _max_query_len is not None: + is_prefill = int(_max_query_len) > 1 + else: + is_prefill = input_ids is not None and inputs_embeds is None and int(input_ids.numel()) > 1 if is_prefill: # Voice clone: replace -100 placeholder positions with ref audio embeddings info_dicts = kwargs.get("model_intermediate_buffer") @@ -278,7 +285,8 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): info_dicts = kwargs.get("runtime_additional_information") hidden_states = self._apply_ref_audio_substitution(hidden_states, input_ids, info_dicts) - # Audio feedback at decode: replace continuation token embeddings + # Audio feedback: replace continuation token embeddings with audio + # embeddings from the last decoded frame (CUDA-graph safe). if input_ids is not None and inputs_embeds is None: hidden_states = self._apply_audio_feedback(hidden_states, input_ids) @@ -411,11 +419,14 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): self._decode_last_codes = self._decode_last_codes.to(hidden_states.device) self._decode_has_codes = self._decode_has_codes.to(hidden_states.device) if bs > self._decode_last_codes.shape[0]: - new_size = max(bs, self._decode_last_codes.shape[0] * 2) - self._decode_last_codes = torch.zeros( - new_size, self.num_codebooks, dtype=torch.long, device=hidden_states.device - ) - self._decode_has_codes = torch.zeros(new_size, dtype=torch.bool, device=hidden_states.device) + old_size = self._decode_last_codes.shape[0] + new_size = max(bs, old_size * 2) + new_codes = torch.zeros(new_size, self.num_codebooks, dtype=torch.long, device=hidden_states.device) + new_has = torch.zeros(new_size, dtype=torch.bool, device=hidden_states.device) + new_codes[:old_size] = self._decode_last_codes + new_has[:old_size] = self._decode_has_codes + self._decode_last_codes = new_codes + self._decode_has_codes = new_has # Compute audio embeddings from last_codes for ALL rows (graph-safe) codes_slice = self._decode_last_codes[:bs] # [bs, N] @@ -561,7 +572,7 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): num_remaining_delays = num_codebooks - last_eos_idx - 1 if num_remaining_delays is not None and num_remaining_delays <= 0: - # Ramp-down complete — terminate + # Ramp-down complete — terminate without emitting this frame. state["num_delay"] = 0 state["num_remaining_delays"] = None state["should_terminate"] = True @@ -578,6 +589,7 @@ class HiggsAudioV3TalkerForConditionalGeneration(nn.Module): state["audio_out_ids"] = this_codes.unsqueeze(-1).clone() else: state["audio_out_ids"] = torch.cat([state["audio_out_ids"], this_codes.unsqueeze(-1)], dim=-1) + new_codes_flat.append(this_codes) # Build full codes tensor [batch_size, num_codebooks] diff --git a/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py b/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py index 95fbe74a5..054b0d66a 100644 --- a/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py +++ b/vllm_omni/model_executor/stage_input_processors/higgs_audio_v3.py @@ -135,7 +135,25 @@ def talker2code2wav( codes_qt = audio_codes.transpose(0, 1).contiguous().cpu() # Step 1: Revert delay pattern - codes_qt = _revert_delay_pattern(codes_qt) + try: + codes_qt = _revert_delay_pattern(codes_qt) + except ValueError: + logger.warning( + "Skipping request: insufficient frames for delay pattern " + "reversal (T=%d < Q=%d). This is a request-level error, " + "not a server-fatal condition.", + codes_qt.shape[1], + codes_qt.shape[0], + ) + code2wav_inputs.append( + OmniTokensPrompt( + prompt_token_ids=[], + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + continue # Step 2: Replace out-of-range codes (BOC=1024, EOC=1025, -1) with 0. # Must use torch.where, NOT clamp: clamp(max=1023) turns 1025→1023 @@ -336,7 +354,21 @@ def talker2code2wav_async_chunk( return None codes_qt = torch.tensor(window_rows, dtype=torch.long).t().contiguous() - de_delayed = _revert_delay_pattern(codes_qt) + try: + de_delayed = _revert_delay_pattern(codes_qt) + except ValueError: + logger.warning( + "async_chunk: insufficient frames for delay pattern reversal " + "(T=%d < Q=%d, request=%s). Returning empty chunk.", + codes_qt.shape[1], + codes_qt.shape[0], + request_id, + ) + emitted_frames.pop(request_id, None) + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) # Replace BOC=1024/EOC=1025 (and any negative pads) with 0; matches the # sync-path substitution. Clamp would turn 1025 into 1023 which is a # VALID codec code and decodes to audible artifacts. From 2f25195750b28dab792629f695afcc7c04825fe5 Mon Sep 17 00:00:00 2001 From: MaciejBalaNV Date: Mon, 8 Jun 2026 21:04:05 +0200 Subject: [PATCH 27/75] Cosmos3 video to video generation (#4266) Signed-off-by: Maciej Bala --- docs/models/supported_models.md | 2 +- docs/serving/videos_api.md | 47 ++- .../offline_inference/cosmos3/inputs/v2v.json | 14 + recipes/README.md | 4 +- recipes/cosmos3/Cosmos3-Nano.md | 99 ++++-- recipes/cosmos3/Cosmos3-Super.md | 38 +- .../models/cosmos3/test_cosmos3_pipeline.py | 54 +++ .../openai_api/test_video_server.py | 177 +++++++++- .../models/cosmos3/pipeline_cosmos3.py | 330 ++++++++++++++++-- vllm_omni/diffusion/models/interface.py | 8 + vllm_omni/entrypoints/openai/api_server.py | 156 ++++++++- .../entrypoints/openai/protocol/videos.py | 17 + vllm_omni/entrypoints/openai/serving_video.py | 32 +- .../entrypoints/openai/video_api_utils.py | 153 +++++++- 14 files changed, 1037 insertions(+), 94 deletions(-) create mode 100644 examples/offline_inference/cosmos3/inputs/v2v.json diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index a0588a5c3..606e6e401 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -33,7 +33,7 @@ th { | `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `WanPipeline` | Wan2.1-T2V, Wan2.2-T2V, Wan2.2-TI2V | `Wan-AI/Wan2.1-T2V-1.3B-Diffusers`, `Wan-AI/Wan2.1-T2V-14B-Diffusers`, `Wan-AI/Wan2.2-T2V-A14B-Diffusers`, `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `WanImageToVideoPipeline` | Wan2.2-I2V | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | -| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | | | | +| `Cosmos3OmniDiffusersPipeline` | Cosmos3 T2I, T2V, I2V, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano` | ✅︎ | | | | | `WanSpeechToVideoPipeline` | Wan2.2-S2V | `Wan-AI/Wan2.2-S2V-14B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `LTX2Pipeline` | LTX-2-T2V | `Lightricks/LTX-2` | ✅︎ | ✅︎ | | | diff --git a/docs/serving/videos_api.md b/docs/serving/videos_api.md index a8caee013..3825cc1ec 100644 --- a/docs/serving/videos_api.md +++ b/docs/serving/videos_api.md @@ -67,8 +67,9 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4 | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `input_reference` | file | null | Uploaded reference image for image-to-video requests | -| `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` | +| `input_reference` | file | null | Uploaded reference image or video for image-to-video/video-to-video requests | +| `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` | | `width` | integer | model default | Output video width | | `height` | integer | model default | Output video height | | `num_frames` | integer | model default | Number of generated frames | @@ -80,6 +81,8 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4 | `flow_shift` | number | null | Scheduler flow-shift value | | `true_cfg_scale` | number | null | True CFG scale when supported by the model | | `seed` | integer | null | Random seed for reproducibility | +| `generate_sound` | boolean | false | Request model-generated audio for video models that support sound generation | +| `sound_duration` | number | null | Duration in seconds for generated audio; defaults to generated video duration | | `negative_prompt` | string | null | Text describing what to avoid in the generated video | | `enable_frame_interpolation` | boolean | null | Enable post-generation frame interpolation | | `frame_interpolation_exp` | integer | null | Interpolation exponent; `1=2x`, `2=4x`, and so on | @@ -123,6 +126,41 @@ curl -s http://localhost:8091/v1/videos \ -F "fps=16" ``` +### Video-to-Video + +For models that support video conditioning, upload the reference video with +`input_reference`: + +```bash +curl -s http://localhost:8091/v1/videos \ + -F "prompt=continue this motion with consistent subjects and lighting" \ + -F "input_reference=@input.mp4;type=video/mp4" \ + -F "width=1280" \ + -F "height=720" \ + -F "num_frames=80" \ + -F "fps=16" +``` + +You can also pass a JSON-safe video URL or `data:video/...;base64,...` payload +through `video_reference`. Do not send `video_reference` together with +`input_reference` or `image_reference`. + +```bash +curl -s http://localhost:8091/v1/videos \ + -F "prompt=continue this motion with consistent subjects and lighting" \ + -F 'video_reference={"video_url":"https://example.com/input.mp4"}' \ + -F "width=1280" \ + -F "height=720" \ + -F "num_frames=80" \ + -F "fps=16" +``` + +JSON references currently support `image_url`/`video_url`; `file_id` references +are not implemented yet. Models may expose additional V2V controls through +`extra_params`. For example, Cosmos3 supports +`condition_frame_indexes_vision` and `condition_video_keep` to select which +decoded reference frames are used as clean conditioning. + ### Synchronous Generation ```bash @@ -146,7 +184,10 @@ export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos ## Model-Specific Examples -For complete text-to-video and image-to-video walkthroughs, see: +For complete text-to-video, image-to-video, and model-specific video-to-video +walkthroughs, see: - [Text-to-Video](../user_guide/examples/online_serving/text_to_video.md) - [Image-to-Video](../user_guide/examples/online_serving/image_to_video.md) +- [Cosmos3 recipes](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-Nano.md) + for model-specific video-to-video examples and conditioning controls diff --git a/examples/offline_inference/cosmos3/inputs/v2v.json b/examples/offline_inference/cosmos3/inputs/v2v.json new file mode 100644 index 000000000..c16c4bbb7 --- /dev/null +++ b/examples/offline_inference/cosmos3/inputs/v2v.json @@ -0,0 +1,14 @@ +{ + "prompt": "A robotic arm, primarily white with black joints and cables, is shown in a clean, modern indoor setting with a white tabletop. The arm, equipped with a gripper holding a small, light green pitcher, is positioned above a clear glass containing a reddish-brown liquid and a spoon. The robotic arm is in the process of pouring a transparent liquid into the glass. To the left of the pitcher, there is an opened jar with a similar reddish-brown substance visible through its transparent body. In the background, a vase with white flowers and a brown couch are partially visible, adding to the contemporary ambiance. The lighting is bright, casting soft shadows on the table. The robotic arm's movements are smooth and controlled, demonstrating precision in its task. As the video progresses, the robotic arm completes the pour, leaving the glass half-filled with the reddish-brown liquid. The jar remains untouched throughout the sequence, and the spoon inside the glass remains stationary. The other robotic arm on the right side also stays stationary throughout the video. The final frame captures the robotic arm with the pitcher finishing the pour, with the glass now filled to a higher level, while the pitcher is slightly tilted but still held securely by the gripper.", + "negative_prompt": "", + "vision_path": "https://github.com/nvidia-cosmos/cosmos-dependencies/raw/refs/heads/assets/cosmos3/inputs/vision/robot_pouring.mp4", + "height": 720, + "width": 1280, + "num_frames": 189, + "num_inference_steps": 35, + "guidance_scale": 6.0, + "flow_shift": 10.0, + "fps": 24, + "condition_frame_indexes_vision": [0, 1], + "condition_video_keep": "first" +} diff --git a/recipes/README.md b/recipes/README.md index 132adfecb..a0833827a 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -36,8 +36,8 @@ recipes/ | [`LTX/LTX-2.md`](./LTX/LTX-2.md) | Text-to-video and image-to-video serving | 1x H200 141GB | | [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) | | [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB | -| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 | -| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 | +| [`cosmos3/Cosmos3-Nano.md`](./cosmos3/Cosmos3-Nano.md) | Text-to-image, text-to-video, image-to-video, video-to-video generation, text to video with sound, action policy | 1x H200 141GB / B300 | +| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V / V2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 | | [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB | | [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB | | [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 | diff --git a/recipes/cosmos3/Cosmos3-Nano.md b/recipes/cosmos3/Cosmos3-Nano.md index 57eb9048c..2ee9c2ed7 100644 --- a/recipes/cosmos3/Cosmos3-Nano.md +++ b/recipes/cosmos3/Cosmos3-Nano.md @@ -1,34 +1,43 @@ # Cosmos3-Nano -> Text-to-image, text-to-video, and image-to-video serving +> Text-to-image, text-to-video, image-to-video, and video-to-video serving ## Summary - Vendor: NVIDIA - Model: `nvidia/Cosmos3-Nano` -- Task: Text-to-image (T2I), text-to-video (T2V), and image-to-video (I2V) generation, with optional synchronized audio (video + sound), action policy +- Task: Text-to-image (T2I), text-to-video (T2V), image-to-video (I2V), and video-to-video (V2V) generation, with optional synchronized audio (video + sound), action policy - Mode: Online serving with the OpenAI-compatible image/video APIs, plus offline generation via the `Omni` API - Maintainer: Community ## When to use this recipe Use this recipe to deploy `nvidia/Cosmos3-Nano` for image and video generation. -A single pipeline class (`Cosmos3OmniDiffusersPipeline`) serves all three modes; -the mode is selected per request: +A single pipeline class (`Cosmos3OmniDiffusersPipeline`) serves these modes; the +mode is selected per request: - **T2I** — `POST /v1/images/generations` (or a prompt carrying `modalities=["image"]`). -- **T2V** — `POST /v1/videos/sync` with `num_frames > 1` and no reference image. +- **T2V** — `POST /v1/videos/sync` with `num_frames > 1` and no reference image/video. - **I2V** — `POST /v1/videos/sync` with a reference image (`input_reference` file upload, or `image_reference` JSON). +- **V2V** — `POST /v1/videos/sync` with a reference video (`input_reference` + video upload, or `video_reference` JSON). Cosmos3 conditions on selected + reference-video latent frames; use `extra_params.condition_frame_indexes_vision` + and `extra_params.condition_video_keep` to choose which prefix/tail frames guide + generation. - **T2VS / I2VS** — add `generate_sound=true` (and optional `sound_duration`) to a T2V/I2V `/v1/videos/sync` request to also generate synchronized audio, muxed into the mp4 as AAC 48 kHz stereo. See the official model card's "Video + Audio" examples. - **Action** — pass `extra_params={"action_mode": ...}` to drive Physical-AI tasks: - - `forward_dynamics` — given a first frame **and** an action trajectory, roll out - the resulting video. Synchronous: `POST /v1/videos/sync`. - - `policy` — given a first frame and a language instruction, **predict** the action - trajectory (and a rollout video). Use the async `POST /v1/videos` endpoint and - read the predicted action from the top-level `action` field + - `forward_dynamics` — given a first frame or video **and** an action trajectory, + roll out the resulting video. Synchronous: `POST /v1/videos/sync`. + - `policy` — given a first frame or video and a language instruction, + **predict** the action trajectory (and a rollout video). Use the async + `POST /v1/videos` endpoint and read the predicted action from the top-level + `action` field. + - `inverse_dynamics` — given a video, **recover** the action trajectory. Use + the async `POST /v1/videos` endpoint and read the recovered action from + the top-level `action` field (`{data, shape, dtype, raw_action_dim, domain_id}`). Action requests also take `domain_name` (e.g. `av`, `bridge_orig_lerobot`, @@ -38,8 +47,9 @@ the mode is selected per request: **`nvidia/Cosmos3-Nano-Policy-DROID`** is served the same way (`domain_name=droid_lerobot`). - `inverse_dynamics` (recover the action from a given video) is supported by the - pipeline; **online inference of inverse dynamics will be added in a follow-up MR.** + Action requests can use `input_reference` or `video_reference` for video input. + `policy` and `forward_dynamics` can also use an image reference; `inverse_dynamics` + requires a video reference. ## References @@ -142,6 +152,36 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \ -F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \ -o cosmos3_i2v.mp4 +# Video-to-video -> /v1/videos/sync with an uploaded reference video. +# By default Cosmos3 conditions on latent indexes [0, 1]. For the default +# temporal VAE stride this decodes only the first 5 input frames. +# The model works best when the prompt describes the actual situation happening in the video. +# Generic prompts may create sub-standard generations. +curl -sS -X POST http://localhost:8000/v1/videos/sync \ + -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Nano" \ + -F "prompt=Continue the same scene with smooth natural motion and consistent subjects." \ + -F "negative_prompt=blurry, distorted, low quality, jittery, deformed" \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" \ + -F "num_inference_steps=35" -F "guidance_scale=6.0" \ + -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"use_resolution_template":false,"use_duration_template":false,"guardrails":true,"condition_frame_indexes_vision":[0,1],"condition_video_keep":"first"}' \ + -F "seed=2222" \ + -F "input_reference=@/path/to/reference.mp4;type=video/mp4" \ + -o cosmos3_v2v.mp4 + +# V2V can also use a JSON-safe URL/data-URL video reference. Do not combine +# video_reference with input_reference or image_reference. +curl -sS -X POST http://localhost:8000/v1/videos/sync \ + -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Nano" \ + -F "prompt=Continue the same scene with smooth natural motion and consistent subjects." \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" \ + -F "num_inference_steps=35" -F "guidance_scale=6.0" \ + -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"condition_frame_indexes_vision":[0,1],"condition_video_keep":"last"}' \ + -F 'video_reference={"video_url":"https://example.com/reference.mp4"}' \ + -o cosmos3_v2v_from_url.mp4 # Text-to-video-with-sound curl -sS -X POST http://localhost:8000/v1/videos/sync \ @@ -190,6 +230,21 @@ VIDEO_ID=$(curl -sS -X POST http://localhost:8000/v1/videos \ # poll until status == completed, then: curl -sS "http://localhost:8000/v1/videos/$VIDEO_ID" | jq '.action | {shape, dtype, raw_action_dim, domain_id}' curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_policy.mp4 + +# Action — inverse dynamics (video -> recovered action trajectory). +# Asynchronous: use the job metadata to read the recovered action. +VIDEO_ID=$(curl -sS -X POST http://localhost:8000/v1/videos \ + -H "Accept: application/json" \ + --form-string "model=nvidia/Cosmos3-Nano" \ + --form-string "prompt=Recover the robot action trajectory from this clip." \ + -F "input_reference=@motion_clip.mp4;type=video/mp4" \ + -F "size=640x480" -F "num_frames=17" -F "fps=5" \ + -F "num_inference_steps=30" -F "guidance_scale=1.0" -F "flow_shift=5.0" \ + --form-string 'extra_params={"action_mode":"inverse_dynamics","domain_name":"bridge_orig_lerobot","raw_action_dim":10,"action_chunk_size":16}' \ + -F "seed=0" | jq -r '.id') +# poll until status == completed, then: +curl -sS "http://localhost:8000/v1/videos/$VIDEO_ID" | jq '.action | {shape, dtype, raw_action_dim, domain_id}' +curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inverse_dynamics.mp4 ``` #### Notes @@ -206,24 +261,27 @@ curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_polic - **Determinism:** identical seed reproduces identical output on the same hardware; outputs are not bit-identical across different GPU types. - **Supported sizes (per model card):** 256p / 480p / 720p at 16:9, 4:3, 1:1, - 3:4, 9:16. Defaults: T2I 1024², 50 steps, guidance 7.0; T2V/I2V 1280×720, - 189 frames, 35 steps, guidance 6.0, `flow_shift=10.0`. + 3:4, 9:16. Defaults: T2I 1024², 50 steps, guidance 7.0; T2V/I2V/V2V + 1280×720, 189 frames, 35 steps, guidance 6.0, `flow_shift=10.0`. - **Key flags / params:** `--no-guardrails` (server) or `extra_params={"guardrails":false}` (per request) toggles safety. The per-request flag only takes effect when the server was launched **with** guardrails enabled (it cannot re-enable them on a `--no-guardrails` server). `use_resolution_template` / `use_duration_template` are off by default and only needed when not using upsampled prompts that already encode resolution/duration. + For V2V, `condition_frame_indexes_vision` selects the clean conditioned latent + frame indexes (default `[0, 1]`), and `condition_video_keep` selects whether the + API decodes the first or last needed reference frames (`"first"` by default). - **Known limitations:** - Guardrails-on requires `cosmos-guardrail` **and** access to the gated `nvidia/Cosmos-1.0-Guardrail` repo (accept license + `HF_TOKEN`); otherwise the server fails at pipeline build with a gated-repo / safety-checker error. - A guardrail-blocked prompt currently returns HTTP 500 (`"Guardrail blocked prompt"`). - - Action `forward_dynamics` (sync `/v1/videos/sync`) and `policy` (async - `/v1/videos`, returns the predicted action under the top-level `action` - field) are supported online. **Online inference of inverse dynamics will be - added in a follow-up MR.** + - Action `forward_dynamics`, `policy`, and `inverse_dynamics` are supported + online. Use async `POST /v1/videos` when you need the predicted/recovered + action payload under the top-level `action` field; sync `/v1/videos/sync` + returns raw MP4 bytes and does not expose action metadata in the response body. ### 1x GPU (Offline generation) @@ -287,8 +345,9 @@ python -c "from PIL import Image; im=Image.open('cosmos3_t2i.png'); print('image - Same `Cosmos3OmniDiffusersPipeline` as online; mode is chosen by `prompt["modalities"]` (`["image"]` → T2I, `["video"]` → T2V) plus - `num_frames`/`fps`, and `multi_modal_data={"image": ...}` for I2V. For video, - frames are returned in `outputs[0].request_output.images` as an + `num_frames`/`fps`, `multi_modal_data={"image": ...}` for I2V, and + `multi_modal_data={"video": []}` or a video tensor/array for V2V. + For video, frames are returned in `outputs[0].request_output.images` as an `(B, F, H, W, 3)` array. - The offline entry must be guarded by `if __name__ == "__main__":` — the engine spawns workers with the `spawn` start method. diff --git a/recipes/cosmos3/Cosmos3-Super.md b/recipes/cosmos3/Cosmos3-Super.md index 1dc88d0c3..7e63ba26d 100644 --- a/recipes/cosmos3/Cosmos3-Super.md +++ b/recipes/cosmos3/Cosmos3-Super.md @@ -1,12 +1,12 @@ # Cosmos3-Super -> Frontier 64B world model: text-to-image, text-to-video, image-to-video (+ optional audio) +> Frontier 64B world model: text-to-image, text-to-video, image-to-video, video-to-video (+ optional audio) ## Summary - Vendor: NVIDIA - Model: `nvidia/Cosmos3-Super` (64B; also `Cosmos3-Super-Text2Image`, `Cosmos3-Super-Image2Video`) -- Task: T2I, T2V, I2V generation, with optional synchronized audio (video + sound) +- Task: T2I, T2V, I2V, V2V generation, with optional synchronized audio (video + sound) - Mode: Online serving with the OpenAI-compatible image/video APIs - Maintainer: Community @@ -16,8 +16,8 @@ Use this recipe to deploy the 64B `nvidia/Cosmos3-Super` for the highest-quality Cosmos3 generation. It shares the same `Cosmos3OmniDiffusersPipeline` and request formats as [Cosmos3-Nano](./Cosmos3-Nano.md) — only the checkpoint size and the recommended parallelism differ. Mode is selected per request (T2I → -`/v1/images/generations`; T2V/I2V → `/v1/videos/sync`; add `generate_sound=true` -for audio). +`/v1/images/generations`; T2V/I2V/V2V → `/v1/videos/sync`; add +`generate_sound=true` for audio). ## References @@ -62,8 +62,9 @@ disable. `--enable-layerwise-offload` reduces VRAM on smaller GPUs. #### Verification Requests are identical to Nano (see [`Cosmos3-Nano.md`](./Cosmos3-Nano.md) for full -T2I/T2V/I2V/T2VS curls); official params: `size=1280x720, num_frames=189, fps=24, -num_inference_steps=35, guidance_scale=6.0, flow_shift=10.0, max_sequence_length=4096`. +T2I/T2V/I2V/V2V/T2VS curls); official params: `size=1280x720, num_frames=189, +fps=24, num_inference_steps=35, guidance_scale=6.0, flow_shift=10.0, +max_sequence_length=4096`. ```bash curl http://localhost:8000/v1/models @@ -84,6 +85,15 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ -F "seed=1111" -F "input_reference=@/path/to/reference.jpg;type=image/jpeg" \ -o cosmos3_super_i2v.mp4 +# V2V — add an uploaded reference video. condition_video_keep can be "first" or "last". +curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Super" -F "prompt=Continue the same scene with smooth natural motion." \ + -F "size=1280x720" -F "num_frames=189" -F "fps=24" -F "num_inference_steps=35" \ + -F "guidance_scale=6.0" -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F 'extra_params={"condition_frame_indexes_vision":[0,1],"condition_video_keep":"first"}' \ + -F "seed=2222" -F "input_reference=@/path/to/reference.mp4;type=video/mp4" \ + -o cosmos3_super_v2v.mp4 + # T2V + sound — add generate_sound/sound_duration (output muxes AAC 48 kHz stereo) curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ -F "model=nvidia/Cosmos3-Super" -F "prompt=A robot arm is cleaning a plate in the kitchen" \ @@ -103,10 +113,12 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync -H "Accept: video/mp4" \ - T2V + sound (189 frames, 35 steps) → **~198 s**, output muxes **AAC 48 kHz stereo** - (NVIDIA's reference: 8×H200 @ 50 steps ≈ 55 s/video; 2×H200 @ 35 steps ≈ 3 min/video.) - **Memory:** ~61.5 GiB per GPU when sharded across 2 GPUs (HSDP shard 2); repo ~135 GB on disk. -- Same generation defaults, supported sizes, and `generate_sound`/`sound_duration` - semantics as Nano, including the **action** modality: `forward_dynamics` - (sync `/v1/videos/sync`) and `policy` (async `/v1/videos`, predicted action under - the top-level `action` field) — see the Cosmos3-Nano recipe for the request shape. - Online inference of inverse dynamics will be added in a follow-up MR. Verified on - the 64B Super under `--cfg-parallel-size 2`: async `policy` returns the predicted - action (`[16, 10]`) and the rollout video reliably. +- Same generation defaults, supported sizes, V2V reference-video controls + (`condition_frame_indexes_vision`, `condition_video_keep`), and + `generate_sound`/`sound_duration` + semantics as Nano, including the **action** modality: `forward_dynamics`, + `policy`, and `inverse_dynamics` — see the Cosmos3-Nano recipe for the request + shapes. Use async `/v1/videos` when you need predicted/recovered action metadata + under the top-level `action` field. Verified on the 64B Super under + `--cfg-parallel-size 2`: async `policy` returns the predicted action (`[16, 10]`) + and the rollout video reliably. diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index 087d1a673..c51f3ff08 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -374,6 +374,19 @@ def test_preprocess_i2v_image_and_action_video_inputs() -> None: assert tuple(additional["preprocessed_image"].shape) == (1, 3, 16, 32) assert tuple(additional["preprocessed_video"].shape) == (1, 3, 3, 16, 32) + frames = [Image.new("RGB", (8, 4), color) for color in ("red", "green", "blue", "yellow", "purple", "black")] + v2v = SimpleNamespace( + prompts=[{"prompt": "Continue.", "multi_modal_data": {"video": frames}}], + sampling_params=SimpleNamespace( + height=16, + width=32, + extra_args={"condition_frame_indexes_vision": [0, 1], "condition_video_keep": "last"}, + ), + ) + additional = preprocess(v2v).prompts[0]["additional_information"] + assert tuple(additional["preprocessed_video"].shape) == (1, 3, 5, 16, 32) + assert additional["condition_frame_indexes_vision"] == [0, 1] + def test_postprocess_handles_image_video_audio_and_validation() -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_post_process_func @@ -464,6 +477,19 @@ def test_prepare_latents_for_video_image_sound_and_action(make_cosmos3_pipeline) assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]]]]] assert image_latent.shape == (1, 2, 1, 2, 3) + pipeline._encode_video_tensor = lambda *args, **kwargs: torch.full((1, 2, 3, 2, 3), 6.0) + v2v_latents, v2v_velocity_mask, v2v_condition = pipeline._prepare_latents_v2v( + torch.zeros(1, 3, 5, 16, 24), + 16, + 24, + 9, + torch.Generator(device="cpu").manual_seed(0), + [0, 1], + ) + torch.testing.assert_close(v2v_latents[:, :, 0:2], torch.full((1, 2, 2, 2, 3), 6.0)) + assert v2v_velocity_mask.tolist() == [[[[[0.0]], [[0.0]], [[1.0]]]]] + assert v2v_condition.shape == (1, 2, 3, 2, 3) + pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, sound_gen=True, sound_dim=3) pipeline._sound_tokenizer = SimpleNamespace( sample_rate=10, @@ -670,6 +696,34 @@ class TestForwardRouting: ) assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is velocity_mask + video_tensor = torch.zeros(1, 3, 5, 16, 16) + v2v_condition = torch.full((1, 2, 2, 1, 1), 4.0) + v2v_mask = torch.tensor([[[[[0.0]], [[1.0]]]]]) + pipeline._prepare_latents_v2v = lambda *args, **kwargs: ( + torch.zeros(1, 2, 2, 1, 1), + v2v_mask, + v2v_condition, + ) + pipeline.forward( + SimpleNamespace( + prompts=[ + { + "prompt": "continue", + "modalities": ["video"], + "additional_information": { + "preprocessed_video": video_tensor, + "condition_frame_indexes_vision": [0], + }, + } + ], + sampling_params=make_sampling_params(height=16, width=16, num_frames=5), + ) + ) + assert captured["flow_shifts"][-1] == 10.0 + assert captured["format"]["negative_prompt"] == "" + assert captured["diffuse_calls"][-1]["shared_kwargs"]["noisy_frame_mask"] is v2v_mask + assert captured["diffuse_calls"][-1]["condition_latents"] is v2v_condition + pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, sound_gen=True, sound_dim=3) sound_latents = torch.zeros(1, 3, 4) pipeline._resolve_sound_target_samples = lambda *args: (20, 2.0, 10) diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index 072fd25f1..b2dc32c6e 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -13,12 +13,14 @@ import threading import time from types import SimpleNamespace +import numpy as np import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from PIL import Image from pytest_mock import MockerFixture +from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes from vllm_omni.entrypoints.openai import api_server from vllm_omni.entrypoints.openai.api_server import router from vllm_omni.entrypoints.openai.protocol.videos import ( @@ -84,7 +86,8 @@ class BlockingVideoHandler: if self.stage_configs is None: self.stage_configs = stage_configs - async def generate_video_bytes(self, request, reference_id, *, reference_image=None): + async def generate_video_bytes(self, request, reference_id, *, reference_image=None, reference_video=None): + del request, reference_id, reference_image, reference_video self.started.set() try: await asyncio.Future() @@ -130,6 +133,30 @@ def _make_test_image_data_url(size=(64, 64)) -> str: return f"data:image/png;base64,{encoded}" +def _make_test_video_bytes(size=(32, 24), num_frames=3) -> bytes: + width, height = size + frames = np.zeros((num_frames, height, width, 3), dtype=np.uint8) + for idx in range(num_frames): + frames[idx, :, :, 0] = idx * 40 + frames[idx, :, :, 1] = 128 + frames[idx, :, :, 2] = 255 - idx * 40 + return mux_video_audio_bytes(frames, fps=8, video_codec_options={"preset": "ultrafast", "threads": "0"}) + + +def _make_test_video_data_url(size=(32, 24), num_frames=3) -> str: + encoded = base64.b64encode(_make_test_video_bytes(size, num_frames)).decode("utf-8") + return f"data:video/mp4;base64,{encoded}" + + +def _cosmos3_stage_configs(): + return [ + SimpleNamespace( + stage_type="diffusion", + engine_args=SimpleNamespace(model_class_name="Cosmos3OmniDiffusersPipeline"), + ) + ] + + def _wait_for_status(client: TestClient, video_id: str, status: str, timeout_s: float = 2.0): deadline = time.time() + timeout_s last_payload = None @@ -332,6 +359,104 @@ def test_i2v_video_generation_with_image_reference_form(test_client, mocker: Moc assert input_image.size == (40, 24) +def test_v2v_video_generation_form(test_client, mocker: MockerFixture): + video_bytes = _make_test_video_bytes((32, 24), num_frames=3) + + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + response = test_client.post( + "/v1/videos", + data={"prompt": "Continue this motion."}, + files={"input_reference": ("input.mp4", video_bytes, "video/mp4")}, + ) + + assert response.status_code == 200 + 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 + prompt = engine.captured_prompt + assert "multi_modal_data" in prompt + assert "video" in prompt["multi_modal_data"] + input_video = prompt["multi_modal_data"]["video"] + assert len(input_video) == 3 + assert all(isinstance(frame, Image.Image) for frame in input_video) + assert input_video[0].size == (32, 24) + + +def test_v2v_video_generation_with_video_reference_form(test_client, mocker: MockerFixture): + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + response = test_client.post( + "/v1/videos", + data={ + "prompt": "Continue this motion.", + "video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}), + }, + ) + + assert response.status_code == 200 + 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 + input_video = engine.captured_prompt["multi_modal_data"]["video"] + assert len(input_video) == 2 + assert input_video[0].size == (32, 24) + + +def test_decode_video_bytes_can_keep_last_frames(): + from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes + + frames = _decode_video_bytes( + _make_test_video_bytes((32, 24), num_frames=6), + source="input_reference", + max_frames=2, + keep="last", + ) + + assert len(frames) == 2 + red_means = [np.asarray(frame)[:, :, 0].mean() for frame in frames] + assert red_means[0] > 100 + assert red_means[1] > red_means[0] + + +def test_cosmos3_reference_video_limit_uses_v2v_condition_frames(): + request = VideoGenerationRequest( + prompt="Continue this motion.", + num_frames=189, + extra_params={"condition_frame_indexes_vision": [0, 2]}, + ) + + spec = api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()) + assert spec.max_frames == 9 + assert spec.keep == "first" + + +def test_cosmos3_reference_video_limit_preserves_action_frames(): + request = VideoGenerationRequest( + prompt="Predict the action.", + num_frames=17, + extra_params={"action_mode": "inverse_dynamics", "action_chunk_size": 16}, + ) + + assert api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()).max_frames == 17 + + +def test_cosmos3_reference_video_limit_caps_condition_frames_to_output_frames(): + request = VideoGenerationRequest( + prompt="Continue this motion.", + num_frames=5, + extra_params={"condition_frame_indexes_vision": [0, 20]}, + ) + + assert api_server._reference_video_decode_spec(request, _cosmos3_stage_configs()).max_frames == 5 + + def test_seconds_defaults_fps_and_frames(test_client, mocker: MockerFixture): fps_values = [] @@ -794,7 +919,20 @@ def test_rejects_input_reference_and_image_reference_together(test_client): files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")}, ) assert response.status_code == 400 - assert "either input_reference or image_reference" in response.json()["detail"].lower() + assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower() + + +def test_rejects_image_reference_and_video_reference_together(test_client): + response = test_client.post( + "/v1/videos", + data={ + "prompt": "bad refs", + "image_reference": '{"image_url": "https://example.com/cat.png"}', + "video_reference": '{"video_url": "https://example.com/cat.mp4"}', + }, + ) + assert response.status_code == 400 + assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower() def test_invalid_seconds_returns_422(test_client): @@ -856,6 +994,18 @@ def test_unsupported_image_reference_file_id_returns_400(test_client): assert response.json()["detail"] == "Invalid image_reference: file_id is not supported yet." +def test_unsupported_video_reference_file_id_returns_400(test_client): + response = test_client.post( + "/v1/videos", + data={ + "prompt": "unsupported ref", + "video_reference": '{"file_id": "file-123"}', + }, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "Invalid video_reference: file_id is not supported yet." + + def test_invalid_uploaded_input_reference_returns_400(test_client): response = test_client.post( "/v1/videos", @@ -863,7 +1013,7 @@ def test_invalid_uploaded_input_reference_returns_400(test_client): files={"input_reference": ("input.png", b"not-an-image", "image/png")}, ) assert response.status_code == 400 - assert response.json()["detail"] == "Invalid input_reference: provided content is not a valid image." + assert response.json()["detail"] == "Invalid input_reference: provided content is not a valid image or video." def test_video_request_validation(): @@ -880,6 +1030,8 @@ def test_video_request_validation(): with pytest.raises(ValueError): VideoGenerationRequest(prompt="test", image_reference={"file_id": "file-1", "image_url": "https://example.com"}) + with pytest.raises(ValueError): + VideoGenerationRequest(prompt="test", video_reference={"file_id": "file-1", "video_url": "https://example.com"}) with pytest.raises(ValueError): VideoGenerationRequest(prompt="test", frame_interpolation_exp=0) with pytest.raises(ValueError): @@ -1265,6 +1417,23 @@ def test_sync_i2v_with_image_reference(test_client, mocker: MockerFixture): assert response.content == b"ref-video" +def test_sync_v2v_returns_video_bytes(test_client, mocker: MockerFixture): + video_bytes = _make_test_video_bytes((32, 24), num_frames=3) + _mock_encode_video_bytes(mocker, b"v2v-video-data") + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "Continue this motion."}, + files={"input_reference": ("input.mp4", video_bytes, "video/mp4")}, + ) + + assert response.status_code == 200 + assert response.content == b"v2v-video-data" + engine = test_client.app.state.openai_serving_video._engine_client + input_video = engine.captured_prompt["multi_modal_data"]["video"] + assert len(input_video) == 3 + assert input_video[0].size == (32, 24) + + def test_sync_missing_handler_returns_503(): app = FastAPI() app.include_router(router) @@ -1297,7 +1466,7 @@ def test_sync_rejects_both_references(test_client): files={"input_reference": ("input.png", _make_test_image_bytes(), "image/png")}, ) assert response.status_code == 400 - assert "either input_reference or image_reference" in response.json()["detail"].lower() + assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower() def test_sync_generation_error_returns_500(test_client, mocker: MockerFixture): diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 501f8ffab..df9eb835d 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -1,8 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Cosmos3 text/image-to-video and text-to-image pipeline for vllm-omni. +"""Cosmos3 text/image/video-to-video and text-to-image pipeline for vllm-omni. -Single pipeline class supports T2V, I2V, and T2I; the mode is selected at +Single pipeline class supports T2V, I2V, V2V, and T2I; the mode is selected at runtime by: * ``prompt["modalities"]`` contains ``"image"``: **T2I** (text-to-image). @@ -10,6 +10,8 @@ runtime by: (text-to-video). * ``multi_modal_data['image']`` present on the prompt: **I2V** (handled by :func:`get_cosmos3_pre_process_func`) +* ``multi_modal_data['video']`` present on the prompt with no action mode: + **V2V** (handled by :func:`get_cosmos3_pre_process_func`) """ @@ -40,7 +42,10 @@ from vllm_omni.diffusion.distributed.parallel_state import ( ) from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader -from vllm_omni.diffusion.models.interface import SupportImageInput +from vllm_omni.diffusion.models.interface import ( + ReferenceVideoDecodeSpec, + SupportImageInput, +) from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest @@ -63,6 +68,17 @@ from .transformer_cosmos3 import Cosmos3VFMTransformer, resolve_sound_gen logger = init_logger(__name__) +COSMOS3_DEFAULT_CONDITION_FRAME_INDEXES_VISION = (0, 1) +COSMOS3_DEFAULT_CONDITION_VIDEO_KEEP = "first" +# Mirrors the WAN VAE's temporal compression. Authoritative value is +# ``self.vae.config.scale_factor_temporal`` at runtime; this constant exists so +# off-line / API code that runs before the pipeline is constructed can compute +# pixel-frame budgets without instantiating the VAE. +COSMOS3_VAE_TEMPORAL_COMPRESSION = 4 +COSMOS3_DEFAULT_CONDITION_PIXEL_FRAMES = ( + max(COSMOS3_DEFAULT_CONDITION_FRAME_INDEXES_VISION) * COSMOS3_VAE_TEMPORAL_COMPRESSION + 1 +) +COSMOS3_V2V_DEFAULT_FLOW_SHIFT = 10.0 COSMOS3_DURATION_TEMPLATE = "The video is {duration:.1f} seconds long and is of {fps:.0f} FPS." COSMOS3_RESOLUTION_TEMPLATE = "This video is of {height}x{width} resolution." COSMOS3_IMAGE_RESOLUTION_TEMPLATE = "This image is of {height}x{width} resolution." @@ -91,16 +107,55 @@ COSMOS3_T2I_DEFAULT_GUIDANCE_INTERVAL: tuple[float, float] = (400.0, 1000.0) COSMOS3_DEFAULT_MAX_SEQUENCE_LENGTH = 4096 +def _normalize_condition_frame_indexes_vision(value: Any) -> tuple[int, ...]: + """Normalize Cosmos3 vision-conditioning latent frame indexes.""" + if value is None: + return COSMOS3_DEFAULT_CONDITION_FRAME_INDEXES_VISION + if isinstance(value, str): + value = [item.strip() for item in value.split(",") if item.strip()] + elif isinstance(value, int): + value = [value] + + if not isinstance(value, Iterable): + raise TypeError( + "Cosmos3 condition_frame_indexes_vision must be an int, comma-separated string, " + f"or iterable of ints; got {type(value)!r}." + ) + + indexes = tuple(sorted({int(index) for index in value})) + if not indexes: + raise ValueError("Cosmos3 condition_frame_indexes_vision must contain at least one index.") + if any(index < 0 for index in indexes): + raise ValueError(f"Cosmos3 condition_frame_indexes_vision must be non-negative, got {indexes}.") + return indexes + + +def _condition_pixel_frame_count( + condition_frame_indexes_vision: Iterable[int], + temporal_compression: int = COSMOS3_VAE_TEMPORAL_COMPRESSION, +) -> int: + return max(condition_frame_indexes_vision) * int(temporal_compression) + 1 + + +def _normalize_condition_video_keep(value: Any) -> str: + keep = str(value or COSMOS3_DEFAULT_CONDITION_VIDEO_KEEP).strip().lower() + if keep not in {"first", "last"}: + raise ValueError("Cosmos3 condition_video_keep must be either 'first' or 'last'.") + return keep + + # --------------------------------------------------------------------------- # Post-process function (registered in registry.py) # --------------------------------------------------------------------------- def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): - """Pre-process function for both T2V and I2V. + """Pre-process function for T2V, I2V, V2V, and action inputs. For T2V (no image in ``multi_modal_data``), the request is returned unchanged after the optional guardrails check. For I2V (image present), the conditioning image is loaded, aspect-resized + center-cropped, and stored back on the prompt as ``additional_information.preprocessed_image``. + For V2V (video present without action mode), source frames are cropped to + the target size and stored as ``additional_information.preprocessed_video``. """ from .guardrails import check_text_safety, ensure_initialized, is_guardrails_enabled @@ -137,7 +192,27 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): return PIL.Image.open(value).convert("RGB") if isinstance(value, PIL.Image.Image): return value.convert("RGB") - raise TypeError(f"Cosmos3 action preprocessing expected PIL image or image path, got {type(value)!r}.") + if isinstance(value, np.ndarray): + array = value + if array.ndim == 3 and array.shape[0] in (3, 4) and array.shape[-1] not in (3, 4): + array = np.transpose(array, (1, 2, 0)) + if np.issubdtype(array.dtype, np.floating): + if array.min() < 0.0 or array.max() > 1.0: + array = np.clip(array, -1.0, 1.0) * 0.5 + 0.5 + array = (np.clip(array, 0.0, 1.0) * 255.0).round().astype(np.uint8) + return PIL.Image.fromarray(array).convert("RGB") + if isinstance(value, torch.Tensor): + tensor = value.detach().cpu() + if tensor.ndim == 3 and tensor.shape[0] in (3, 4): + tensor = tensor.permute(1, 2, 0) + if tensor.is_floating_point(): + if tensor.min().item() < 0.0 or tensor.max().item() > 1.0: + tensor = tensor.clamp(-1.0, 1.0) * 0.5 + 0.5 + tensor = (tensor.clamp(0.0, 1.0) * 255.0).round().to(torch.uint8) + return PIL.Image.fromarray(tensor.numpy()).convert("RGB") + raise TypeError( + f"Cosmos3 preprocessing expected PIL image, numpy array, torch tensor, or path, got {type(value)!r}." + ) def _resize_and_pad_action_image(image: PIL.Image.Image, target_h: int, target_w: int) -> PIL.Image.Image: scale = min(target_w / image.width, target_h / image.height, 1.0) @@ -170,6 +245,61 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): processed = [_preprocess_action_image(_pil_to_rgb(frame), target_h, target_w).squeeze(0) for frame in frames] return torch.stack(processed, dim=1).unsqueeze(0).contiguous() + def _preprocess_condition_image(image: PIL.Image.Image, target_h: int, target_w: int) -> torch.Tensor: + scale = max(target_w / image.width, target_h / image.height) + resize_w = int(np.ceil(scale * image.width)) + resize_h = int(np.ceil(scale * image.height)) + image = image.resize((resize_w, resize_h), PIL.Image.Resampling.LANCZOS) + left = (resize_w - target_w) // 2 + top = (resize_h - target_h) // 2 + image = image.crop((left, top, left + target_w, top + target_h)) + return video_processor.preprocess(image, height=target_h, width=target_w) + + def _video_payload_to_frames(video: Any) -> list[Any]: + if isinstance(video, list): + return video + if isinstance(video, torch.Tensor): + tensor = video.detach().cpu() + if tensor.ndim == 5: + if tensor.shape[0] != 1: + raise TypeError("Cosmos3 video preprocessing supports only batch size 1.") + tensor = tensor[0] + if tensor.ndim == 4 and tensor.shape[0] in (3, 4) and tensor.shape[-1] not in (3, 4): + return [tensor[:, i] for i in range(tensor.shape[1])] + if tensor.ndim == 4 and tensor.shape[-1] in (3, 4): + return [tensor[i] for i in range(tensor.shape[0])] + if isinstance(video, np.ndarray): + array = video + if array.ndim == 5: + if array.shape[0] != 1: + raise TypeError("Cosmos3 video preprocessing supports only batch size 1.") + array = array[0] + if array.ndim == 4 and array.shape[0] in (3, 4) and array.shape[-1] not in (3, 4): + return [array[:, i] for i in range(array.shape[1])] + if array.ndim == 4 and array.shape[-1] in (3, 4): + return [array[i] for i in range(array.shape[0])] + raise TypeError("Cosmos3 video input must be a non-empty list of frames or a single video tensor/array.") + + def _select_video_frames(frames: list[Any], max_frames: int, keep: str) -> list[Any]: + if not frames: + raise ValueError("Cosmos3 video input must contain at least one frame.") + if keep == "last": + return frames[-max_frames:] + return frames[:max_frames] + + def _preprocess_condition_video( + frames: list[Any], + target_h: int, + target_w: int, + max_frames: int, + keep: str, + ) -> torch.Tensor: + selected = _select_video_frames(frames, max_frames, keep) + processed = [ + _preprocess_condition_image(_pil_to_rgb(frame), target_h, target_w).squeeze(0) for frame in selected + ] + return torch.stack(processed, dim=1).unsqueeze(0).contiguous() + def pre_process_func(request: OmniDiffusionRequest) -> OmniDiffusionRequest: action_mode = _request_action_mode(request) if is_guardrails_enabled(od_config, request.sampling_params): @@ -183,16 +313,23 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): multi_modal_data = prompt.get("multi_modal_data", {}) or {} raw_image = multi_modal_data.get("image") raw_video = multi_modal_data.get("video") - if raw_image is None and not (action_mode is not None and raw_video is not None): + if raw_image is None and raw_video is None: continue + if raw_image is not None and raw_video is not None and action_mode is None: + raise ValueError("Cosmos3 non-action generation accepts either image or video input, not both.") if "additional_information" not in prompt: prompt["additional_information"] = {} + raw_video_frames: list[Any] | None = None + if raw_video is not None: + raw_video_frames = _video_payload_to_frames(raw_video) + if not raw_video_frames: + raise TypeError("Cosmos3 video input must be a non-empty list of PIL images or image paths.") + if raw_image is None: - if not isinstance(raw_video, list) or not raw_video: - raise TypeError("Cosmos3 action video input must be a non-empty list of PIL images or image paths.") - image = _pil_to_rgb(raw_video[0]) + assert raw_video_frames is not None # raw_image and raw_video can't both be None here + image = _pil_to_rgb(raw_video_frames[0]) else: image = _pil_to_rgb(raw_image) @@ -219,23 +356,38 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): int(target_h), int(target_w), ) - else: - scale = max(target_w / image.width, target_h / image.height) - resize_w = int(np.ceil(scale * image.width)) - resize_h = int(np.ceil(scale * image.height)) - image = image.resize((resize_w, resize_h), PIL.Image.Resampling.LANCZOS) - left = (resize_w - target_w) // 2 - top = (resize_h - target_h) // 2 - image = image.crop((left, top, left + target_w, top + target_h)) - - prompt["additional_information"]["preprocessed_image"] = video_processor.preprocess( - image, height=target_h, width=target_w + elif raw_video is None: + prompt["additional_information"]["preprocessed_image"] = _preprocess_condition_image( + image, + int(target_h), + int(target_w), ) - if action_mode is not None and raw_video is not None: - if not isinstance(raw_video, list): - raise TypeError("Cosmos3 action video input must be a list of PIL images or image paths.") + else: + assert raw_video_frames is not None + extra = _extra_args(request) + condition_frame_indexes_vision = _normalize_condition_frame_indexes_vision( + extra.get( + "condition_frame_indexes_vision", + prompt.get("condition_frame_indexes_vision"), + ) + ) + keep = _normalize_condition_video_keep( + extra.get("condition_video_keep", prompt.get("condition_video_keep")) + ) + max_frames = _condition_pixel_frame_count(condition_frame_indexes_vision) + prompt["additional_information"]["preprocessed_video"] = _preprocess_condition_video( + raw_video_frames, + int(target_h), + int(target_w), + max_frames, + keep, + ) + prompt["additional_information"]["condition_frame_indexes_vision"] = list( + condition_frame_indexes_vision + ) + if action_mode is not None and raw_video_frames is not None: prompt["additional_information"]["preprocessed_video"] = _preprocess_action_video( - raw_video, + raw_video_frames, int(target_h), int(target_w), ) @@ -345,13 +497,13 @@ def get_cosmos3_post_process_func(od_config: OmniDiffusionConfig): class Cosmos3OmniDiffusersPipeline( nn.Module, CFGParallelMixin, SupportImageInput, ProgressBarMixin, DiffusionPipelineProfilerMixin ): - """Cosmos3 text/image-to-video / text-to-image pipeline. + """Cosmos3 text/image/video-to-video / text-to-image pipeline. Architecture: Mixture-of-Transformers with Qwen3-VL backbone. - Understanding pathway: causal self-attention on text (runs once, K/V cached) - Generation pathway: cross-attention on noisy visual latents (runs each step) - Supports T2V, I2V, and T2I from the same class. Mode is selected at + Supports T2V, I2V, V2V, and T2I from the same class. Mode is selected at runtime: * **T2I** when ``prompt["modalities"]`` contains ``"image"``. Latent @@ -365,12 +517,45 @@ class Cosmos3OmniDiffusersPipeline( Frame 0 of the initial latent is set to the VAE-encoded conditioning image, frame-0 noise predictions are masked to zero, and the clean image latent is re-injected at frame 0 after each scheduler step. + * **V2V** when the request supplies a preprocessed video via + ``multi_modal_data['video']`` without an action mode. Explicit latent + frame indexes are kept clean with ``noisy_frame_mask`` and re-injected + after each scheduler step. * **T2V** otherwise (default video generation). """ support_image_input: ClassVar[bool] = True color_format: ClassVar[str] = "RGB" + @classmethod + def reference_video_decode_spec( + cls, + *, + num_frames: int | None = None, + extra_args: dict[str, Any] | None = None, + ) -> ReferenceVideoDecodeSpec: + extra_args = extra_args if isinstance(extra_args, dict) else {} + action_mode = normalize_action_mode(extra_args.get("action_mode")) + if action_mode is not None: + if num_frames is not None: + return ReferenceVideoDecodeSpec(max_frames=int(num_frames), keep="first") + action_chunk_size = extra_args.get("action_chunk_size") + if action_chunk_size is not None: + try: + max_frames = int(action_chunk_size) + 1 + except (TypeError, ValueError): + max_frames = None + if max_frames is not None and max_frames > 0: + return ReferenceVideoDecodeSpec(max_frames=max_frames, keep="first") + return ReferenceVideoDecodeSpec(max_frames=None, keep="first") + + condition_indexes = _normalize_condition_frame_indexes_vision(extra_args.get("condition_frame_indexes_vision")) + max_frames = _condition_pixel_frame_count(condition_indexes) + if num_frames is not None: + max_frames = min(max_frames, int(num_frames)) + keep = _normalize_condition_video_keep(extra_args.get("condition_video_keep")) + return ReferenceVideoDecodeSpec(max_frames=max_frames, keep=keep) + def __init__( self, *, @@ -1188,6 +1373,70 @@ class Cosmos3OmniDiffusersPipeline( velocity_mask = 1.0 - condition_mask return latents, velocity_mask, image_latent + def _prepare_latents_v2v( + self, + video_tensor: torch.Tensor, + height: int, + width: int, + num_frames: int, + generator: torch.Generator, + condition_frame_indexes_vision: Iterable[int] | int | str | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Prepare V2V latents with explicit clean conditioned latent frames.""" + del height, width + if video_tensor.ndim == 4: + video_tensor = video_tensor.unsqueeze(0) + if video_tensor.ndim != 5 or video_tensor.shape[0] != 1 or video_tensor.shape[1] != 3: + raise ValueError(f"Cosmos3 video tensor must have shape [1, 3, T, H, W], got {tuple(video_tensor.shape)}.") + if video_tensor.shape[2] < 1: + raise ValueError("Cosmos3 V2V video tensor must contain at least one frame.") + + C = self.transformer.latent_channel_size + T_lat = (num_frames - 1) // self.vae_scale_factor_temporal + 1 + H_lat = video_tensor.shape[-2] // self.vae_scale_factor_spatial + W_lat = video_tensor.shape[-1] // self.vae_scale_factor_spatial + indexes = _normalize_condition_frame_indexes_vision(condition_frame_indexes_vision) + out_of_range = [index for index in indexes if index >= T_lat] + if out_of_range: + raise ValueError( + "Cosmos3 condition_frame_indexes_vision contains indexes outside the latent video: " + f"indexes={indexes}, latent_frames={T_lat}." + ) + + noise = randn_tensor( + (1, C, T_lat, H_lat, W_lat), + generator=generator, + device=self.device, + dtype=self.dtype, + ) + condition_pixel_frames = _condition_pixel_frame_count(indexes, self.vae_scale_factor_temporal) + condition_video = video_tensor[:, :, :condition_pixel_frames] + if condition_video.shape[2] < condition_pixel_frames: + pad = condition_video[:, :, -1:].repeat(1, 1, condition_pixel_frames - condition_video.shape[2], 1, 1) + condition_video = torch.cat([condition_video, pad], dim=2) + + cond_prefix_latent = self._encode_video_tensor(condition_video) + expected_prefix = (1, C, max(indexes) + 1, H_lat, W_lat) + if ( + cond_prefix_latent.shape[0] != expected_prefix[0] + or cond_prefix_latent.shape[1] != expected_prefix[1] + or cond_prefix_latent.shape[2] < expected_prefix[2] + or cond_prefix_latent.shape[3:] != expected_prefix[3:] + ): + raise ValueError( + "Cosmos3 V2V condition latent shape mismatch: " + f"encoded={tuple(cond_prefix_latent.shape)}, expected at least {expected_prefix}." + ) + + condition_mask = torch.zeros(1, 1, T_lat, 1, 1, device=self.device, dtype=self.dtype) + condition_latents = torch.zeros_like(noise) + for index in indexes: + condition_mask[:, :, index, :, :] = 1.0 + condition_latents[:, :, index : index + 1] = cond_prefix_latent[:, :, index : index + 1] + latents = condition_mask * condition_latents + (1.0 - condition_mask) * noise + velocity_mask = 1.0 - condition_mask + return latents, velocity_mask, condition_latents + def _prepare_latents_action_video( self, video_tensor: torch.Tensor, @@ -1567,19 +1816,20 @@ class Cosmos3OmniDiffusersPipeline( prompt = prompt_data negative_prompt = None image_tensor = None - action_video_tensor = None + video_tensor = None else: prompt = prompt_data.get("prompt", "") negative_prompt = prompt_data.get("negative_prompt") additional_info = prompt_data.get("additional_information", {}) or {} image_tensor = additional_info.get("preprocessed_image") - action_video_tensor = additional_info.get("preprocessed_video") + video_tensor = additional_info.get("preprocessed_video") sp = req.sampling_params is_t2i = self._is_t2i_request(req) sound_enabled = self._is_sound_request(prompt_data, sp) action_mode = self._get_action_mode(prompt_data, sp) action_enabled = action_mode is not None + action_video_tensor = video_tensor if action_enabled else None if action_enabled and is_t2i: raise ValueError("Cosmos3 action generation is supported only for video outputs.") if action_enabled and sound_enabled: @@ -1603,6 +1853,11 @@ class Cosmos3OmniDiffusersPipeline( ) if negative_prompt is None: negative_prompt = "" + if image_tensor is not None and video_tensor is not None and not action_enabled: + raise ValueError("Cosmos3 non-action generation accepts either image or video input, not both.") + if video_tensor is not None and is_t2i: + raise ValueError("Cosmos3 video-to-video generation is supported only for video outputs.") + is_v2v = video_tensor is not None and not is_t2i and not action_enabled # T2I and T2V share the same model + forward path; only defaults # differ: @@ -1626,7 +1881,7 @@ class Cosmos3OmniDiffusersPipeline( # Fall back to the engine-init shift, NOT None: passing None # to ``_set_flow_shift`` would leak a prior T2I rebuild # (shift=3.0) into a subsequent video request. - default_flow_shift = self._engine_init_flow_shift + default_flow_shift = COSMOS3_V2V_DEFAULT_FLOW_SHIFT if is_v2v else self._engine_init_flow_shift default_guidance_interval = None batch_size = 1 # Existing video pipeline assumes B=1. @@ -1775,6 +2030,23 @@ class Cosmos3OmniDiffusersPipeline( generator, ) image_latent = condition_latents[:, :, 0:1] + elif is_v2v: + condition_frame_indexes_vision = _normalize_condition_frame_indexes_vision( + self._get_sp_param( + sp, + "condition_frame_indexes_vision", + self._get_prompt_param(prompt_data, "condition_frame_indexes_vision", None), + ) + ) + latents, velocity_mask, condition_latents = self._prepare_latents_v2v( + video_tensor, + height, + width, + num_frames, + generator, + condition_frame_indexes_vision, + ) + image_latent = None elif image_tensor is not None and not is_t2i: latents, velocity_mask, image_latent = self._prepare_latents_i2v( image_tensor, diff --git a/vllm_omni/diffusion/models/interface.py b/vllm_omni/diffusion/models/interface.py index f0ded9cdb..d4ace761b 100644 --- a/vllm_omni/diffusion/models/interface.py +++ b/vllm_omni/diffusion/models/interface.py @@ -2,10 +2,12 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from __future__ import annotations +from dataclasses import dataclass from typing import ( TYPE_CHECKING, Any, ClassVar, + Literal, Protocol, runtime_checkable, ) @@ -23,6 +25,12 @@ class SupportImageInput(Protocol): color_format: ClassVar[str] = "RGB" # Default color format +@dataclass(frozen=True) +class ReferenceVideoDecodeSpec: + max_frames: int | None = None + keep: Literal["first", "last"] = "first" + + @runtime_checkable class SupportAudioInput(Protocol): support_audio_input: ClassVar[bool] = True diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 38acd6d55..67a639b15 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -91,6 +91,7 @@ from vllm.utils import random_uuid from vllm.utils.system_utils import decorate_logs from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError +from vllm_omni.diffusion.models.interface import ReferenceVideoDecodeSpec from vllm_omni.entrypoints.async_omni import AsyncOmni from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError from vllm_omni.entrypoints.openai.image_api_utils import ( @@ -125,7 +126,7 @@ from vllm_omni.entrypoints.openai.serving_audio_generate import OmniOpenAIServin from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler -from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage +from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo, ReferenceImage, ReferenceVideo from vllm_omni.entrypoints.openai.serving_video_stream import OmniStreamingVideoHandler from vllm_omni.entrypoints.openai.stage_params import ( build_stage_sampling_params_list, @@ -2531,6 +2532,84 @@ def _parse_form_json(value: str | None, expected_type: type | None = None) -> An return parsed +def _config_get(config: Any, key: str, default: Any = None) -> Any: + if isinstance(config, dict): + return config.get(key, default) + if hasattr(config, "get"): + try: + return config.get(key, default) + except Exception: + pass + return getattr(config, key, default) + + +def _stage_engine_args(stage_cfg: Any) -> Any: + return _config_get(stage_cfg, "engine_args", {}) or {} + + +def _diffusion_model_classes(stage_configs: list[Any] | None) -> list[type]: + if not stage_configs: + return [] + + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + model_classes: list[type] = [] + for stage_cfg in stage_configs: + if get_stage_type(stage_cfg) != "diffusion": + continue + model_class_name = _config_get(_stage_engine_args(stage_cfg), "model_class_name") + if not model_class_name: + continue + model_cls = DiffusionModelRegistry._try_load_model_cls(model_class_name) + if model_cls is not None: + model_classes.append(model_cls) + return model_classes + + +def _normalize_reference_video_decode_spec(spec: ReferenceVideoDecodeSpec) -> ReferenceVideoDecodeSpec: + max_frames = spec.max_frames + if max_frames is not None: + try: + max_frames = int(max_frames) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="Invalid reference video decode spec: max_frames must be an integer.", + ) from exc + if max_frames <= 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="Invalid reference video decode spec: max_frames must be positive.", + ) + + keep = str(spec.keep or "first").strip().lower() + if keep not in {"first", "last"}: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="Invalid reference video decode spec: keep must be either 'first' or 'last'.", + ) + return ReferenceVideoDecodeSpec(max_frames=max_frames, keep=cast(Literal["first", "last"], keep)) + + +def _reference_video_decode_spec( + req: VideoGenerationRequest, + stage_configs: list[Any] | None, +) -> ReferenceVideoDecodeSpec: + video_params = req.resolve_video_params() + extra_params = req.extra_params if isinstance(req.extra_params, dict) else {} + for model_cls in _diffusion_model_classes(stage_configs): + resolver = getattr(model_cls, "reference_video_decode_spec", None) + if resolver is None: + continue + try: + spec = resolver(num_frames=video_params.num_frames, extra_args=extra_params) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST.value, detail=str(exc)) from exc + if spec is not None: + return _normalize_reference_video_decode_spec(spec) + return ReferenceVideoDecodeSpec(max_frames=video_params.num_frames, keep="first") + + def video_response_from_request(model_name: str, req: VideoGenerationRequest) -> VideoResponse: resp = VideoResponse( model=model_name, @@ -2597,6 +2676,7 @@ async def _run_video_generation_job( request: VideoGenerationRequest, video_id: str, reference_image: ReferenceImage | None = None, + reference_video: ReferenceVideo | None = None, app_state: Any | None = None, ) -> None: job = await VIDEO_STORE.get(video_id) @@ -2609,7 +2689,10 @@ async def _run_video_generation_job( output_path = None try: video_bytes, stage_durations, peak_memory_mb, action = await handler.generate_video_bytes( - request, video_id, reference_image=reference_image + request, + video_id, + reference_image=reference_image, + reference_video=reference_video, ) file_name = f"{video_id}.{job.file_extension}" @@ -2676,6 +2759,7 @@ async def _parse_video_form( prompt: str = Form(...), input_reference: UploadFile | None = File(default=None), image_reference: str | None = Form(default=None), + video_reference: str | None = Form(default=None), model: str | None = Form(default=None), seconds: SecondStr | None = Form(default=None), size: SizeStr | None = Form(default=None), @@ -2700,7 +2784,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), -) -> tuple[VideoGenerationRequest, "OmniOpenAIServingVideo", str, ReferenceImage | None]: +) -> tuple[VideoGenerationRequest, "OmniOpenAIServingVideo", str, ReferenceImage | None, ReferenceVideo | None]: """FastAPI dependency that parses video form data, validates inputs, resolves the handler, and decodes any reference image. @@ -2708,11 +2792,15 @@ async def _parse_video_form( """ input_reference_bytes = await input_reference.read() if input_reference is not None else None parsed_image_reference = _parse_form_json(image_reference) + parsed_video_reference = _parse_form_json(video_reference) - if parsed_image_reference is not None and input_reference_bytes is not None: + provided_references = sum( + item is not None for item in (parsed_image_reference, parsed_video_reference, input_reference_bytes) + ) + if provided_references > 1: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST.value, - detail="Provide either input_reference or image_reference, not both.", + detail="Provide only one of input_reference, image_reference, or video_reference.", ) request_data: dict[str, Any] = { @@ -2721,6 +2809,7 @@ async def _parse_video_form( "seconds": seconds, "size": size, "image_reference": parsed_image_reference, + "video_reference": parsed_video_reference, "user": user, "width": width, "height": height, @@ -2774,13 +2863,28 @@ async def _parse_video_form( detail=f"Video generation setup failed: {str(e)}", ) + decode_spec = ReferenceVideoDecodeSpec() + if parsed_video_reference is not None or input_reference_bytes is not None: + stage_configs = ( + handler.stage_configs + or app_stage_configs + or getattr(getattr(handler, "_engine_client", None), "stage_configs", None) + ) + decode_spec = _reference_video_decode_spec(request, stage_configs) try: - image_data = await decode_input_reference(request.image_reference, input_reference_bytes) + media_data = await decode_input_reference( + request.image_reference, + request.video_reference, + input_reference_bytes, + max_video_frames=decode_spec.max_frames, + video_keep=decode_spec.keep, + ) except InvalidInputReferenceError as exc: raise HTTPException(400, detail=str(exc) or "Invalid input reference.") from exc - reference_image = ReferenceImage(data=image_data) if image_data is not None else None - return request, handler, effective_model_name, reference_image + reference_image = ReferenceImage(data=media_data) if isinstance(media_data, Image.Image) else None + reference_video = ReferenceVideo(data=media_data) if isinstance(media_data, list) else None + return request, handler, effective_model_name, reference_image, reference_video @router.post( @@ -2794,18 +2898,31 @@ async def _parse_video_form( ) async def create_video( raw_request: Request, - ctx: tuple[VideoGenerationRequest, OmniOpenAIServingVideo, str, ReferenceImage | None] = Depends(_parse_video_form), + ctx: tuple[ + VideoGenerationRequest, + OmniOpenAIServingVideo, + str, + ReferenceImage | None, + ReferenceVideo | None, + ] = Depends(_parse_video_form), ) -> VideoResponse: """Create an asynchronous video generation job. 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 = ctx + request, handler, effective_model_name, reference_image, reference_video = ctx ref = video_response_from_request(effective_model_name, request) await VIDEO_STORE.upsert(ref.id, ref) task = asyncio.create_task( - _run_video_generation_job(handler, request, ref.id, reference_image, app_state=raw_request.app.state) + _run_video_generation_job( + handler, + request, + ref.id, + reference_image, + reference_video, + app_state=raw_request.app.state, + ) ) await VIDEO_TASKS.upsert(ref.id, task) return ref @@ -2822,7 +2939,13 @@ async def create_video( ) async def create_video_sync( raw_request: Request, - ctx: tuple[VideoGenerationRequest, OmniOpenAIServingVideo, str, ReferenceImage | None] = Depends(_parse_video_form), + ctx: tuple[ + VideoGenerationRequest, + OmniOpenAIServingVideo, + str, + ReferenceImage | None, + ReferenceVideo | None, + ] = Depends(_parse_video_form), ) -> Response: """Synchronous video generation endpoint. @@ -2833,13 +2956,18 @@ 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 = ctx + request, handler, effective_model_name, reference_image, reference_video = ctx request_id = f"video_sync-{random_uuid()}" 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), + handler.generate_video_bytes( + request, + request_id, + reference_image=reference_image, + reference_video=reference_video, + ), timeout=VIDEO_SYNC_TIMEOUT_S, ) except asyncio.TimeoutError: diff --git a/vllm_omni/entrypoints/openai/protocol/videos.py b/vllm_omni/entrypoints/openai/protocol/videos.py index ec5ab14e8..7cca71ad1 100644 --- a/vllm_omni/entrypoints/openai/protocol/videos.py +++ b/vllm_omni/entrypoints/openai/protocol/videos.py @@ -73,6 +73,19 @@ class UrlImageReference(BaseModel): ImageReference = UrlImageReference | FileImageReference +class FileVideoReference(BaseModel): + model_config = ConfigDict(extra="forbid") + file_id: str + + +class UrlVideoReference(BaseModel): + model_config = ConfigDict(extra="forbid") + video_url: str + + +VideoReference = UrlVideoReference | FileVideoReference + + class VideoGenerationRequest(BaseModel): """ OpenAI-style video generation request. @@ -99,6 +112,10 @@ class VideoGenerationRequest(BaseModel): default=None, description="Optional JSON-safe image reference that guides generation. Provide either image_url or file_id.", ) + video_reference: VideoReference | None = Field( + default=None, + description="Optional JSON-safe video reference that guides generation. Provide either video_url or file_id.", + ) # Video params block for extensibility video_params: VideoParams | None = Field(default=None, description="Optional video-specific parameters") diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py index 8673e1923..f1cd32a76 100644 --- a/vllm_omni/entrypoints/openai/serving_video.py +++ b/vllm_omni/entrypoints/openai/serving_video.py @@ -39,6 +39,13 @@ class ReferenceImage: data: Image.Image +@dataclass +class ReferenceVideo: + """Reference video frames for video-conditioned generation.""" + + data: list[Image.Image] + + @dataclass class VideoGenerationArtifacts: """Normalized outputs and profiler metadata extracted from one request.""" @@ -96,6 +103,7 @@ class OmniOpenAIServingVideo: reference_id: str, *, reference_image: ReferenceImage | None = None, + reference_video: ReferenceVideo | None = None, ) -> VideoGenerationArtifacts: """Run the generation pipeline and extract video/audio/profiler outputs.""" prompt: OmniTextPrompt = OmniTextPrompt(prompt=request.prompt, modalities=["video"]) @@ -105,6 +113,12 @@ class OmniOpenAIServingVideo: gen_params = self._resolve_default_sampling_params() input_image = None if reference_image is None else reference_image.data + input_video = None if reference_video is None else reference_video.data + if input_image is not None and input_video is not None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="Provide either an image reference or a video reference, not both.", + ) vp = request.resolve_video_params() if input_image is not None and vp.width is not None and vp.height is not None: target_size = (vp.width, vp.height) @@ -112,6 +126,8 @@ class OmniOpenAIServingVideo: input_image = input_image.resize(target_size, Image.Resampling.LANCZOS) if input_image is not None: prompt["multi_modal_data"] = {"image": input_image} + elif input_video is not None: + prompt["multi_modal_data"] = {"video": input_video} if vp.width is not None and vp.height is not None: gen_params.width = vp.width gen_params.height = vp.height @@ -211,8 +227,14 @@ class OmniOpenAIServingVideo: reference_id: str, *, reference_image: ReferenceImage | None = None, + reference_video: ReferenceVideo | None = None, ) -> VideoGenerationResponse: - artifacts = await self._run_and_extract(request, reference_id, reference_image=reference_image) + artifacts = await self._run_and_extract( + request, + reference_id, + reference_image=reference_image, + reference_video=reference_video, + ) video_codec_options = {"preset": "ultrafast", "threads": "0"} if request.extra_params is not None and isinstance(request.extra_params, dict): @@ -252,9 +274,15 @@ class OmniOpenAIServingVideo: reference_id: str, *, reference_image: ReferenceImage | None = None, + reference_video: ReferenceVideo | None = None, ) -> tuple[bytes, dict[str, float], float, VideoAction | None]: """Generate a video and return raw MP4 bytes, bypassing base64 encoding.""" - artifacts = await self._run_and_extract(request, reference_id, reference_image=reference_image) + artifacts = await self._run_and_extract( + request, + reference_id, + reference_image=reference_image, + reference_video=reference_video, + ) if len(artifacts.videos) > 1: logger.warning( "Video request %s generated %d outputs; returning only the first.", diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py index 303b3b4a1..d4cb38ea3 100644 --- a/vllm_omni/entrypoints/openai/video_api_utils.py +++ b/vllm_omni/entrypoints/openai/video_api_utils.py @@ -8,8 +8,9 @@ from __future__ import annotations import base64 import binascii +from collections import deque from io import BytesIO -from typing import Any +from typing import Any, Literal import httpx import numpy as np @@ -19,8 +20,11 @@ from PIL import Image, UnidentifiedImageError from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError from vllm_omni.entrypoints.openai.protocol.videos import ( FileImageReference, + FileVideoReference, ImageReference, UrlImageReference, + UrlVideoReference, + VideoReference, ) @@ -31,6 +35,70 @@ def _decode_image_bytes(image_bytes: bytes, *, source: str) -> Image.Image: raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid image.") from exc +def _decode_video_bytes( + video_bytes: bytes, + *, + source: str, + max_frames: int | None = None, + keep: Literal["first", "last"] = "first", +) -> list[Image.Image]: + try: + import av + except ImportError as exc: # pragma: no cover - av is a serving dependency via media_utils + raise InvalidInputReferenceError(f"Invalid {source}: video decoding requires PyAV.") from exc + + if keep not in {"first", "last"}: + raise InvalidInputReferenceError(f"Invalid {source}: video frame selection must be 'first' or 'last'.") + if max_frames is not None and max_frames <= 0: + raise InvalidInputReferenceError(f"Invalid {source}: max video frames must be positive.") + + frames: list[Image.Image] = [] + tail_frames: deque[Image.Image] | None = ( + deque(maxlen=max_frames) if keep == "last" and max_frames is not None else None + ) + try: + with av.open(BytesIO(video_bytes)) as container: + for frame in container.decode(video=0): + image = frame.to_image().convert("RGB") + if tail_frames is not None: + tail_frames.append(image) + else: + frames.append(image) + if keep == "first" and max_frames is not None and len(frames) >= max_frames: + break + except Exception as exc: + raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.") from exc + + if tail_frames is not None: + frames = list(tail_frames) + if not frames: + raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid video.") + return frames + + +def _decode_media_bytes( + media_bytes: bytes, + *, + source: str, + max_video_frames: int | None = None, + video_keep: Literal["first", "last"] = "first", +) -> Image.Image | list[Image.Image]: + try: + return _decode_image_bytes(media_bytes, source=source) + except InvalidInputReferenceError: + try: + return _decode_video_bytes( + media_bytes, + source=source, + max_frames=max_video_frames, + keep=video_keep, + ) + except InvalidInputReferenceError as video_exc: + raise InvalidInputReferenceError( + f"Invalid {source}: provided content is not a valid image or video." + ) from video_exc + + def _decode_base64_image(input_reference: str, *, source: str) -> Image.Image: if input_reference: if input_reference.startswith("data:image"): @@ -64,23 +132,96 @@ async def decode_image_url(image_url: str) -> Image.Image: raise InvalidInputReferenceError("Invalid image_reference.image_url: must be an http(s) URL or data URL.") +def _decode_base64_video( + video_reference: str, + *, + source: str, + max_frames: int | None = None, + keep: Literal["first", "last"] = "first", +) -> list[Image.Image]: + if video_reference: + if video_reference.startswith("data:video"): + _, b64_data = video_reference.split(",", 1) + else: + b64_data = video_reference + + try: + video_bytes = base64.b64decode(b64_data) + except (binascii.Error, ValueError) as exc: # pragma: no cover - malformed base64 + raise InvalidInputReferenceError(f"Invalid {source}: video data is not valid base64.") from exc + return _decode_video_bytes(video_bytes, source=source, max_frames=max_frames, keep=keep) + raise InvalidInputReferenceError(f"Invalid {source}: video data is empty.") + + +async def decode_video_url( + video_url: str, + *, + max_frames: int | None = None, + keep: Literal["first", "last"] = "first", +) -> list[Image.Image]: + if video_url.startswith("data:video"): + return _decode_base64_video( + video_url, + source="video_reference.video_url", + max_frames=max_frames, + keep=keep, + ) + + if video_url.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=60) as client: + try: + response = await client.get(video_url) + response.raise_for_status() + except httpx.HTTPError as exc: + raise InvalidInputReferenceError( + "Invalid video_reference.video_url: failed to download video." + ) from exc + return _decode_video_bytes( + response.content, + source="video_reference.video_url", + max_frames=max_frames, + keep=keep, + ) + + raise InvalidInputReferenceError("Invalid video_reference.video_url: must be an http(s) URL or data URL.") + + async def decode_input_reference( image_reference: ImageReference | None, + video_reference: VideoReference | None, input_reference_bytes: bytes | None, -) -> Image.Image | None: - """Decode image input from multipart bytes, base64/data URL, or image_reference.""" + *, + max_video_frames: int | None = None, + video_keep: Literal["first", "last"] = "first", +) -> Image.Image | list[Image.Image] | None: + """Decode media input from multipart bytes, data URLs, or typed references.""" - if input_reference_bytes is not None and image_reference is not None: - raise InvalidInputReferenceError("Provide either input_reference or image_reference, not both.") + provided = sum(item is not None for item in (input_reference_bytes, image_reference, video_reference)) + if provided > 1: + raise InvalidInputReferenceError("Provide only one of input_reference, image_reference, or video_reference.") if isinstance(input_reference_bytes, bytes): - return _decode_image_bytes(input_reference_bytes, source="input_reference") + return _decode_media_bytes( + input_reference_bytes, + source="input_reference", + max_video_frames=max_video_frames, + video_keep=video_keep, + ) if isinstance(image_reference, UrlImageReference): return await decode_image_url(image_reference.image_url) elif isinstance(image_reference, FileImageReference): raise InvalidInputReferenceError("Invalid image_reference: file_id is not supported yet.") + if isinstance(video_reference, UrlVideoReference): + return await decode_video_url( + video_reference.video_url, + max_frames=max_video_frames, + keep=video_keep, + ) + elif isinstance(video_reference, FileVideoReference): + raise InvalidInputReferenceError("Invalid video_reference: file_id is not supported yet.") + return None From b5352a5869b27701c62dd45d43e0bcea83376508 Mon Sep 17 00:00:00 2001 From: SYLAR <125541396+lishunyang12@users.noreply.github.com> Date: Tue, 9 Jun 2026 03:44:20 +0800 Subject: [PATCH 28/75] [Quant][Perf] Default Blackwell FP8 GEMM to quack CuteDSL fused-bias kernel (#4241) Signed-off-by: WeiQing Chen --- docs/user_guide/quantization/fp8.md | 44 +++++++++ pyproject.toml | 6 ++ vllm_omni/patch.py | 12 +++ vllm_omni/quantization/quack_fp8.py | 134 ++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 vllm_omni/quantization/quack_fp8.py diff --git a/docs/user_guide/quantization/fp8.md b/docs/user_guide/quantization/fp8.md index c8f14548c..1abcd4389 100644 --- a/docs/user_guide/quantization/fp8.md +++ b/docs/user_guide/quantization/fp8.md @@ -28,6 +28,50 @@ in deep DiT blocks. Legend: `✅` supported, `❌` unsupported, `⭕` not verified in this guide. FP8 on Ampere may use a weight-only path where available. +### Faster FP8 GEMM on Blackwell (quack) + +On Blackwell (SM 100+), vLLM runs FP8 linears through the FlashInfer kernel, which +applies the bias as a separate kernel after the GEMM. On the small GEMMs in video +DiTs this bias add is a significant overhead. Installing the optional `quack` kernel +lets vLLM-Omni fuse `alpha * (A @ B) + bias` into a single CuteDSL GEMM, recovering +that overhead (e.g. HunyuanVideo-1.5 FP8 goes from slower-than-BF16 to faster). + +```bash +# CUDA 12.9 +pip install vllm-omni[quack] + +# CUDA 13.x +pip install 'quack-kernels[cu13]' --extra-index-url https://download.pytorch.org/whl/cu130 +``` + +It is enabled automatically once installed (no flag needed) and is **Blackwell-only**: +on Hopper/Ada the CUTLASS FP8 kernel already fuses bias, so quack is not used there. +Set `VLLM_OMNI_USE_QUACK_FP8=0` to force the FlashInfer path. If `quack-kernels` is +not installed, FP8 still works — it just keeps the unfused FlashInfer path. + +#### Compile cache and warmup + +quack JIT-compiles its kernel once per distinct GEMM shape (tens of seconds, longer +the first time across all autotuned configs). The compiled `.o` files are cached on +disk and reused on later runs, so this is a one-time cost — **not** per request. + +vLLM-Omni points that cache at `~/.cache/vllm_omni/quack` (override with +`QUACK_CACHE_DIR`) instead of quack's default under `/tmp`, so it survives restarts. +In containers, set `QUACK_CACHE_DIR` to a mounted/persistent path — or bake it into +the image — so the first cold start does not recompile. The engine's startup dummy +run already exercises the kernels, so with a warm cache the first real request is fast. + +To pre-warm specific shapes (e.g. at image build time): + +```python +from vllm_omni.quantization.quack_fp8 import warmup_quack_fp8 +# (M, K, N) per linear; M = number of tokens for your resolution/frame count +warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)]) +``` + +> The PyPI package is `quack-kernels` (imported as `quack`); plain `pip install +> quack` is an unrelated statistics library. Requires CUDA 12.9+ and Python 3.12. + ## Model Type Support ### Diffusion Model (Qwen-Image, Wan2.2) diff --git a/pyproject.toml b/pyproject.toml index 19cdc0a26..09b8e23ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,12 @@ minicpmo = [ "stepaudio2-minicpmo", ] +# Optional Blackwell-only fused-bias FP8 GEMM; package is `quack-kernels` (imported +# as `quack`), not the unrelated `quack`. See docs/user_guide/quantization/fp8.md. +quack = [ + "quack-kernels>=0.3.11", +] + docs = [ "mkdocs>=1.5.0", "mkdocs-api-autonav", diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py index c343276b1..59d697c16 100644 --- a/vllm_omni/patch.py +++ b/vllm_omni/patch.py @@ -257,3 +257,15 @@ def _patch_flashinfer_fp8_scaled_mm_output_shape(): _patch_flashinfer_fp8_scaled_mm_output_shape() + + +def _patch_fp8_use_quack_fused_bias(): + try: + from vllm_omni.quantization.quack_fp8 import install_quack_fp8_patch + + install_quack_fp8_patch() + except Exception: # noqa: BLE001 + pass + + +_patch_fp8_use_quack_fused_bias() diff --git a/vllm_omni/quantization/quack_fp8.py b/vllm_omni/quantization/quack_fp8.py new file mode 100644 index 000000000..ba68d078d --- /dev/null +++ b/vllm_omni/quantization/quack_fp8.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""FP8 scaled-MM with a fused bias epilogue, backed by quack's CuteDSL GEMM.""" + +from __future__ import annotations + +import os + +import torch +from vllm.logger import init_logger + +logger = init_logger(__name__) + +_TRUTHY = {"1", "true", "yes", "on"} +_gemm_interface = None + + +def _is_blackwell() -> bool: + try: + if not torch.cuda.is_available(): + return False + return torch.cuda.get_device_capability()[0] >= 10 + except Exception: # noqa: BLE001 + return False + + +def quack_enabled() -> bool: + override = os.environ.get("VLLM_OMNI_USE_QUACK_FP8") + if override is not None: + return override.lower() in _TRUTHY + return _is_blackwell() + + +def _set_persistent_cache_dir() -> None: + if os.environ.get("QUACK_CACHE_DIR"): + return + root = ( + os.environ.get("VLLM_CACHE_ROOT") + or os.environ.get("XDG_CACHE_HOME") + or os.path.join(os.path.expanduser("~"), ".cache") + ) + os.environ["QUACK_CACHE_DIR"] = os.path.join(root, "vllm_omni", "quack") + + +def _load_quack(): + global _gemm_interface + if _gemm_interface is not None: + return _gemm_interface or None + try: + _set_persistent_cache_dir() + + import cutlass + import cutlass.base_dsl + import cutlass.base_dsl.arch as arch + + if not hasattr(cutlass.base_dsl, "Arch"): + cutlass.base_dsl.Arch = arch.Arch + + import quack.gemm_interface as gemm_interface + from quack.cute_dsl_utils import torch2cute_dtype_map + + torch2cute_dtype_map.setdefault(torch.float8_e4m3fn, cutlass.Float8E4M3FN) + torch2cute_dtype_map.setdefault(torch.float8_e5m2, cutlass.Float8E5M2) + + _gemm_interface = gemm_interface + logger.info("Quack FP8 fused-bias GEMM enabled (CuteDSL).") + return gemm_interface + except Exception as exc: # noqa: BLE001 + logger.warning("Quack FP8 unavailable, using FlashInfer: %s", exc) + _gemm_interface = False + return None + + +def quack_scaled_fp8_mm( + a: torch.Tensor, + b: torch.Tensor, + scale_a: torch.Tensor, + scale_b: torch.Tensor, + out_dtype: torch.dtype, + bias: torch.Tensor | None = None, +) -> torch.Tensor | None: + gemm = _load_quack() + if gemm is None: + return None + out = torch.empty(a.shape[0], b.shape[1], device=a.device, dtype=out_dtype) + alpha = scale_a.reshape(1).float() * scale_b.reshape(1).float() + gemm.gemm_tuned(a, b, out, bias=bias, alpha=alpha) + return out + + +def install_quack_fp8_patch() -> None: + if not quack_enabled(): + return + if _load_quack() is None: + return + try: + from vllm.model_executor.kernels.linear.scaled_mm.flashinfer import ( + FlashInferFP8ScaledMMLinearKernel, + ) + except ImportError: + return + + original = FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm + if getattr(original, "_omni_quack_patched", False): + return + + def apply_scaled_mm(self, *, A, B, out_dtype, As, Bs, bias, output_shape): # noqa: N803 + try: + out = quack_scaled_fp8_mm(A, B, As, Bs, out_dtype, bias) + if out is not None: + return out.view(*output_shape) + except Exception as exc: # noqa: BLE001 + logger.warning_once("Quack FP8 GEMM failed (%s); using FlashInfer.", exc) + return original(self, A=A, B=B, out_dtype=out_dtype, As=As, Bs=Bs, bias=bias, output_shape=output_shape) + + apply_scaled_mm._omni_quack_patched = True + FlashInferFP8ScaledMMLinearKernel.apply_scaled_mm = apply_scaled_mm + logger.info("Patched FlashInfer FP8 ScaledMM to use quack fused-bias GEMM.") + + +def warmup_quack_fp8( + shapes: list[tuple[int, int, int]], + device: str = "cuda", + out_dtype: torch.dtype = torch.bfloat16, +) -> None: + if _load_quack() is None: + return + scale = torch.ones(1, device=device, dtype=torch.float32) + for m, k, n in shapes: + a = torch.zeros(m, k, device=device, dtype=torch.float8_e4m3fn) + b = torch.zeros(k, n, device=device, dtype=torch.float8_e4m3fn) + quack_scaled_fp8_mm(a, b, scale, scale, out_dtype) + if torch.cuda.is_available(): + torch.accelerator.synchronize() From b8f68174adda0dbe478193e86b5122347cdfb2ae Mon Sep 17 00:00:00 2001 From: Honghan Zhu Date: Tue, 9 Jun 2026 07:34:35 +0800 Subject: [PATCH 29/75] =?UTF-8?q?[Refactor]=20Remove=20dead=20legacy=20pip?= =?UTF-8?q?eline.yaml=20loader=20and=20duplicate=20diff=E2=80=A6=20(#4023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dph97 Co-authored-by: Claude Opus 4.8 Co-authored-by: zhuhonghan --- docs/api/README.md | 1 - tests/entrypoints/test_omni_sleep_mode.py | 5 +- tests/test_config_factory.py | 386 ---------------------- vllm_omni/config/__init__.py | 2 - vllm_omni/config/stage_config.py | 295 +---------------- vllm_omni/diffusion/data.py | 3 - 6 files changed, 7 insertions(+), 685 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index dce02e13d..d4c580629 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -92,7 +92,6 @@ Core scheduling and caching components. Configuration classes. - [vllm_omni.config.model.OmniModelConfig][] -- [vllm_omni.config.stage_config.ModelPipeline][] - [vllm_omni.config.stage_config.StageConfig][] - [vllm_omni.config.stage_config.StageConfigFactory][] - [vllm_omni.config.stage_config.StageType][] diff --git a/tests/entrypoints/test_omni_sleep_mode.py b/tests/entrypoints/test_omni_sleep_mode.py index 1365b320d..c2a5ff0a1 100644 --- a/tests/entrypoints/test_omni_sleep_mode.py +++ b/tests/entrypoints/test_omni_sleep_mode.py @@ -532,7 +532,10 @@ async def test_pure_diffusion_scenario(tp_size: int): try: await engine.sleep(level=1) await engine.wake_up() - async for _ in engine.generate("test", sampling_params=SamplingParams()): + async for _ in engine.generate( + "test", + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256), + ): pass logger.info("Pure diffusion OK (TP=%s)", tp_size) finally: diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index e541d3628..e3ebea88f 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -17,7 +17,6 @@ from tests.helpers.stage_config import get_deploy_config_path from vllm_omni.config.stage_config import ( _PIPELINE_REGISTRY, DeployConfig, - ModelPipeline, PipelineConfig, StageConfig, StageConfigFactory, @@ -351,93 +350,6 @@ class TestStageConfig: assert "parallel_config" not in omega_config.engine_args -class TestModelPipeline: - """Tests for ModelPipeline class.""" - - def test_valid_linear_dag(self): - """Test validation of a valid linear DAG.""" - stages = [ - StageConfig(stage_id=0, model_stage="thinker", input_sources=[]), - StageConfig(stage_id=1, model_stage="talker", input_sources=[0]), - StageConfig(stage_id=2, model_stage="code2wav", input_sources=[1]), - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert errors == [], f"Unexpected errors: {errors}" - - def test_valid_branching_dag(self): - """Test validation of a valid branching DAG.""" - stages = [ - StageConfig(stage_id=0, model_stage="input", input_sources=[]), - StageConfig(stage_id=1, model_stage="branch_a", input_sources=[0]), - StageConfig(stage_id=2, model_stage="branch_b", input_sources=[0]), - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert errors == [], f"Unexpected errors: {errors}" - - def test_missing_entry_point(self): - """Test that missing entry point is detected.""" - stages = [ - StageConfig(stage_id=0, model_stage="stage_a", input_sources=[1]), - StageConfig(stage_id=1, model_stage="stage_b", input_sources=[0]), - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert any("entry point" in e.lower() for e in errors) - - def test_missing_dependency(self): - """Test that missing stage reference is detected.""" - stages = [ - StageConfig(stage_id=0, model_stage="input", input_sources=[]), - StageConfig(stage_id=1, model_stage="output", input_sources=[99]), # Invalid - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert any("non-existent" in e.lower() for e in errors) - - def test_duplicate_stage_ids(self): - """Test that duplicate stage IDs are detected.""" - stages = [ - StageConfig(stage_id=0, model_stage="stage_a", input_sources=[]), - StageConfig(stage_id=0, model_stage="stage_b", input_sources=[]), # Duplicate - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert any("duplicate" in e.lower() for e in errors) - - def test_self_reference(self): - """Test that self-references are detected.""" - stages = [ - StageConfig(stage_id=0, model_stage="entry", input_sources=[]), - StageConfig(stage_id=1, model_stage="self_ref", input_sources=[1]), # Self - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - errors = pipeline.validate_pipeline() - assert any("itself" in e.lower() for e in errors) - - def test_get_stage_by_id(self): - """Test getting stage by ID.""" - stages = [ - StageConfig(stage_id=0, model_stage="thinker", input_sources=[]), - StageConfig(stage_id=1, model_stage="talker", input_sources=[0]), - ] - pipeline = ModelPipeline(model_type="test", stages=stages) - - stage = pipeline.get_stage(1) - assert stage is not None - assert stage.model_stage == "talker" - - missing = pipeline.get_stage(99) - assert missing is None - - def test_empty_pipeline(self): - """Test validation of empty pipeline.""" - pipeline = ModelPipeline(model_type="test", stages=[]) - errors = pipeline.validate_pipeline() - assert any("no stages" in e.lower() for e in errors) - - class TestStageConfigFactory: """Tests for StageConfigFactory class.""" @@ -598,304 +510,6 @@ class TestStageResolutionHelpers: assert overridden == [] -class TestPipelineYamlParsing: - """Tests for pipeline YAML file parsing (@ZJY0516).""" - - def test_parse_qwen3_omni_moe_yaml(self, tmp_path): - """Test parsing the qwen3_omni_moe pipeline YAML.""" - yaml_content = """\ -model_type: qwen3_omni_moe -async_chunk: false - -stages: - - stage_id: 0 - model_stage: thinker - stage_type: llm - input_sources: [] - worker_type: ar - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - hf_config_name: thinker_config - final_output: true - final_output_type: text - is_comprehension: true - - - stage_id: 1 - model_stage: talker - stage_type: llm - input_sources: [0] - worker_type: ar - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - hf_config_name: talker_config - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker - - - stage_id: 2 - model_stage: code2wav - stage_type: llm - input_sources: [1] - worker_type: generation - scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler - hf_config_name: thinker_config - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav - final_output: true - final_output_type: audio -""" - yaml_file = tmp_path / "qwen3_omni_moe.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "qwen3_omni_moe") - - assert pipeline.model_type == "qwen3_omni_moe" - assert len(pipeline.stages) == 3 - - # Stage 0: thinker - s0 = pipeline.stages[0] - assert s0.stage_id == 0 - assert s0.model_stage == "thinker" - assert s0.stage_type == StageType.LLM - assert s0.input_sources == [] - assert s0.worker_type == "ar" - assert s0.final_output is True - assert s0.final_output_type == "text" - assert s0.is_comprehension is True - - # Stage 1: talker - s1 = pipeline.stages[1] - assert s1.stage_id == 1 - assert s1.input_sources == [0] - assert s1.custom_process_input_func == ( - "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker" - ) - assert s1.final_output is False - - # Stage 2: code2wav - s2 = pipeline.stages[2] - assert s2.stage_id == 2 - assert s2.input_sources == [1] - assert s2.worker_type == "generation" - assert s2.final_output_type == "audio" - - def test_parse_yaml_with_legacy_engine_input_source(self, tmp_path): - """Test backward compatibility with engine_input_source field.""" - yaml_content = """\ -model_type: legacy_model - -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - - stage_id: 1 - model_stage: downstream - stage_type: llm - engine_input_source: [0] -""" - yaml_file = tmp_path / "legacy.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "legacy_model") - assert pipeline.stages[1].input_sources == [0] - - def test_parse_yaml_with_connectors_and_edges(self, tmp_path): - """Test parsing pipeline with optional connectors and edges.""" - yaml_content = """\ -model_type: test_model - -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - input_sources: [] - -connectors: - type: ray - -edges: - - from: 0 - to: 1 -""" - yaml_file = tmp_path / "with_connectors.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_model") - assert pipeline.connectors == {"type": "ray"} - assert pipeline.edges == [{"from": 0, "to": 1}] - - def test_parsed_pipeline_passes_validation(self, tmp_path): - """Test that a well-formed YAML produces a valid pipeline.""" - yaml_content = """\ -model_type: valid_model - -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - input_sources: [] - final_output: true - final_output_type: text - - stage_id: 1 - model_stage: next - stage_type: llm - input_sources: [0] -""" - yaml_file = tmp_path / "valid.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "valid_model") - errors = pipeline.validate_pipeline() - assert errors == [], f"Unexpected validation errors: {errors}" - - def test_parse_yaml_migrates_runtime_max_batch_size(self, tmp_path): - """Test that runtime.max_batch_size is migrated to engine_args.max_num_seqs.""" - yaml_content = """\ -model_type: legacy_batch -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - input_sources: [] - runtime: - devices: "0" - max_batch_size: 16 - engine_args: - model_arch: SomeModel -""" - yaml_file = tmp_path / "legacy_batch.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "legacy_batch") - s0 = pipeline.stages[0] - assert "max_batch_size" not in s0.yaml_runtime - assert s0.yaml_engine_args.get("max_num_seqs") == 16 - - def test_parse_diffusion_stage_type(self, tmp_path): - """Test parsing a diffusion stage type from YAML.""" - yaml_content = """\ -model_type: diff_model - -stages: - - stage_id: 0 - model_stage: dit - stage_type: diffusion - input_sources: [] - final_output: true - final_output_type: image -""" - yaml_file = tmp_path / "diffusion.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "diff_model") - assert pipeline.stages[0].stage_type == StageType.DIFFUSION - - def test_parse_mimo_audio_yaml(self, tmp_path): - """Test parsing the MiMo Audio pipeline YAML.""" - yaml_content = """\ -model_type: mimo_audio - -stages: - - stage_id: 0 - model_stage: fused_thinker_talker - stage_type: llm - input_sources: [] - worker_type: ar - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - is_comprehension: true - final_output: true - final_output_type: text - - - stage_id: 1 - model_stage: code2wav - stage_type: llm - input_sources: [0] - worker_type: generation - scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav - final_output: true - final_output_type: audio -""" - yaml_file = tmp_path / "mimo_audio.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "mimo_audio") - - assert pipeline.model_type == "mimo_audio" - assert len(pipeline.stages) == 2 - - # Stage 0: fused_thinker_talker - s0 = pipeline.stages[0] - assert s0.stage_id == 0 - assert s0.model_stage == "fused_thinker_talker" - assert s0.input_sources == [] - assert s0.worker_type == "ar" - assert s0.is_comprehension is True - assert s0.final_output is True - assert s0.final_output_type == "text" - - # Stage 1: code2wav - s1 = pipeline.stages[1] - assert s1.stage_id == 1 - assert s1.input_sources == [0] - assert s1.worker_type == "generation" - assert s1.custom_process_input_func == ( - "vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav" - ) - assert s1.final_output is True - assert s1.final_output_type == "audio" - - # Pipeline validation - errors = pipeline.validate_pipeline() - assert errors == [], f"Unexpected errors: {errors}" - - -class TestAsyncChunk: - """Tests for async_chunk pipeline flag.""" - - def test_model_pipeline_async_chunk_default(self): - """Test ModelPipeline defaults async_chunk to False.""" - stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])] - pipeline = ModelPipeline(model_type="test", stages=stages) - assert pipeline.async_chunk is False - - def test_model_pipeline_async_chunk_set(self): - """Test ModelPipeline with async_chunk=True.""" - stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])] - pipeline = ModelPipeline(model_type="test", stages=stages, async_chunk=True) - assert pipeline.async_chunk is True - - def test_parse_async_chunk_from_yaml(self, tmp_path): - """Test that async_chunk is parsed from pipeline YAML.""" - yaml_content = """\ -model_type: test_async -async_chunk: true - -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - input_sources: [] -""" - yaml_file = tmp_path / "async.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_async") - assert pipeline.async_chunk is True - - def test_parse_missing_async_chunk_defaults_false(self, tmp_path): - """Test that missing async_chunk defaults to False.""" - yaml_content = """\ -model_type: test_no_async - -stages: - - stage_id: 0 - model_stage: entry - stage_type: llm - input_sources: [] -""" - yaml_file = tmp_path / "no_async.yaml" - yaml_file.write_text(yaml_content) - - pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_no_async") - assert pipeline.async_chunk is False - - class TestPipelineDiscovery: """Tests for the central pipeline registry (``pipeline_registry._OMNI_PIPELINES``).""" diff --git a/vllm_omni/config/__init__.py b/vllm_omni/config/__init__.py index f02c07588..e5762456f 100644 --- a/vllm_omni/config/__init__.py +++ b/vllm_omni/config/__init__.py @@ -6,7 +6,6 @@ from vllm_omni.config.lora import LoRAConfig from vllm_omni.config.model import OmniModelConfig from vllm_omni.config.stage_config import ( DeployConfig, - ModelPipeline, PipelineConfig, StageConfig, StageConfigFactory, @@ -30,7 +29,6 @@ __all__ = [ "LoRAConfig", "StageConfig", "StageConfigFactory", - "ModelPipeline", "StageType", "StageExecutionType", "StagePipelineConfig", diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index ed1d1ca18..43e00a142 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -20,13 +20,6 @@ from vllm_omni.config.yaml_util import create_config, load_yaml_config, to_dict from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler, OmniARScheduler from vllm_omni.core.sched.omni_generation_scheduler import OmniGenerationScheduler -_MODELS_DIR = Path(__file__).resolve().parent.parent / "model_executor" / "models" - - -def get_pipeline_path(model_dir: str, filename: str) -> Path: - return _MODELS_DIR / model_dir / filename - - logger = init_logger(__name__) @@ -1061,77 +1054,6 @@ class StageConfig: return create_config(config_dict) -@dataclass -class ModelPipeline: - """Complete pipeline definition for a multi-stage model (legacy). - - TODO(@lishunyang12): remove once all models migrate to PipelineConfig. - """ - - model_type: str - stages: list[StageConfig] - - # Pipeline-wide behavior flags - async_chunk: bool = False - - # Optional distributed configuration - connectors: dict[str, Any] | None = None - edges: list[dict[str, Any]] | None = None - - def get_stage(self, stage_id: int) -> StageConfig | None: - """Look up a stage by its ID. - - Args: - stage_id: The stage ID to search for. - - Returns: - The matching StageConfig, or None if not found. - """ - for stage in self.stages: - if stage.stage_id == stage_id: - return stage - return None - - def validate_pipeline(self) -> list[str]: - """Validate pipeline topology at model integration time (not runtime). - - Checks: - - All stage IDs are unique - - All input_sources reference valid stage IDs - - At least one entry point (stage with empty input_sources) - - Returns: - List of validation error messages. Empty list if valid. - """ - errors: list[str] = [] - - if not self.stages: - errors.append("Topology has no stages defined") - return errors - - # Check for unique stage IDs - stage_ids = [s.stage_id for s in self.stages] - if len(stage_ids) != len(set(stage_ids)): - errors.append("Duplicate stage IDs found") - - stage_id_set = set(stage_ids) - - # Check input_sources reference valid stages - for stage in self.stages: - for source_id in stage.input_sources: - if source_id not in stage_id_set: - errors.append(f"Stage {stage.stage_id} references non-existent input source {source_id}") - if source_id == stage.stage_id: - errors.append(f"Stage {stage.stage_id} references itself as input source") - - # Check for at least one entry point - entry_points = [s for s in self.stages if not s.input_sources] - if not entry_points: - errors.append("No entry point found (stage with empty input_sources)") - - return errors - - class StageConfigFactory: """Factory that loads pipeline YAML and merges CLI overrides. @@ -1206,33 +1128,9 @@ class StageConfigFactory: continue return cls._create_from_registry(registered.model_type, cli_overrides, deploy_config_path) - # --- Legacy path: load from pipeline YAML --- - pipeline = cls._load_pipeline(model, trust_remote_code=trust_remote_code) - - if pipeline is None: - return None - - errors = pipeline.validate_pipeline() - if errors: - logger.warning(f"Pipeline validation warnings for {model}: {errors}") - - # Materialize the resolved pipeline-wide async_chunk value into every - # stage so build_engine_args_dict() can inject the stage connector - # spec and explicit False overrides are preserved. - resolved_async_chunk = cli_overrides.get("async_chunk") - if resolved_async_chunk is None: - resolved_async_chunk = bool(pipeline.async_chunk) - for stage in pipeline.stages: - stage.yaml_engine_args["async_chunk"] = bool(resolved_async_chunk) - - # Apply CLI overrides - result: list[StageConfig] = [] - for stage in pipeline.stages: - # Merge global CLI overrides - stage.runtime_overrides = cls._merge_cli_overrides(stage, cli_overrides) - result.append(stage) - - return result + # Not in the pipeline registry — let the caller fall back to the + # legacy ``stage_configs/*.yaml`` path (resolve_model_config_path). + return None @classmethod def _create_from_registry( @@ -1355,193 +1253,6 @@ class StageConfigFactory: return [config_dict] - @classmethod - def _load_pipeline(cls, model: str, trust_remote_code: bool = True) -> ModelPipeline | None: - """Load a legacy ``pipeline.yaml`` for the model. - - Searches ``model_executor/models/