From 5c51782c6bec3c7976c528e5899e89452278c448 Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Thu, 2 Jul 2026 01:23:35 -0600 Subject: [PATCH 001/101] [Bugfix] Realign Serving Tokenization Name to Fix Error Handling Routes (#4816) Signed-off-by: Alex Brooks --- tests/entrypoints/openai_api/test_serving_speech.py | 6 +++--- vllm_omni/entrypoints/openai/api_server.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index c3e4bd960..0c2006536 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -2530,7 +2530,7 @@ def test_api_server_delete_voice_exception_returns_500(mocker: MockerFixture): def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixture): fake_base = _patch_api_server_base(mocker) raw_request = _make_api_server_request(None, path="/v1/audio/speech") - raw_request.app.state.openai_serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = fake_base request = OpenAICreateSpeechRequest(input="Hello") response = asyncio.run(api_server_module.create_speech(request, raw_request)) @@ -2543,7 +2543,7 @@ def test_api_server_create_speech_without_handler_returns_404(mocker: MockerFixt def test_api_server_create_speech_batch_without_handler_returns_404(mocker: MockerFixture): fake_base = _patch_api_server_base(mocker) raw_request = _make_api_server_request(None, path="/v1/audio/speech/batch") - raw_request.app.state.openai_serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = fake_base request = BatchSpeechRequest(items=[SpeechBatchItem(input="hi")]) response = asyncio.run(api_server_module.create_speech_batch(request, raw_request)) @@ -2612,7 +2612,7 @@ def test_api_server_create_audio_generate_without_handler_returns_404(mocker: Mo fake_base = _patch_api_server_base(mocker) raw_request = _make_api_server_request(None, path="/v1/audio/generate") raw_request.app.state.openai_serving_audio_generate = None - raw_request.app.state.openai_serving_tokenization = fake_base + raw_request.app.state.serving_tokenization = fake_base request = OpenAICreateAudioGenerateRequest(input="a bird singing") response = asyncio.run(api_server_module.create_audio_generate(request, raw_request)) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 40f3ed5a2..991e702e3 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -751,7 +751,7 @@ async def omni_init_app_state( state.diffusion_engine = engine_client state.openai_serving_models = _DiffusionServingModels(base_model_paths) # OMNI: tokenization endpoints are not supported in pure diffusion mode. - state.openai_serving_tokenization = None + state.serving_tokenization = None # Use for_diffusion method to create chat handler state.openai_serving_chat = OmniOpenAIServingChat.for_diffusion( @@ -1014,7 +1014,7 @@ async def omni_init_app_state( if any(t in supported_tasks for t in ("embed", "score", "token_embed")) else None ) - state.openai_serving_tokenization = ServingTokenization( + state.serving_tokenization = ServingTokenization( state.openai_serving_models, state.openai_serving_render, request_logger=request_logger, @@ -1154,7 +1154,7 @@ async def create_chat_completion(request: ChatCompletionRequest, raw_request: Re metrics_header_format = raw_request.headers.get(ENDPOINT_LOAD_METRICS_FORMAT_HEADER_LABEL, "") handler = Omnichat(raw_request) if handler is None: - base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None) + base_server = getattr(raw_request.app.state, "serving_tokenization", None) if base_server is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND.value, @@ -1245,7 +1245,7 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request """ handler = Omnispeech(raw_request) if handler is None: - base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None) + base_server = getattr(raw_request.app.state, "serving_tokenization", None) if base_server is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND.value, @@ -1283,7 +1283,7 @@ async def create_speech(request: OpenAICreateSpeechRequest, raw_request: Request async def create_speech_batch(request: BatchSpeechRequest, raw_request: Request): handler = Omnispeech(raw_request) if handler is None: - base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None) + base_server = getattr(raw_request.app.state, "serving_tokenization", None) if base_server is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND.value, @@ -1326,7 +1326,7 @@ async def create_speech_batch(request: BatchSpeechRequest, raw_request: Request) async def create_audio_generate(request: OpenAICreateAudioGenerateRequest, raw_request: Request): handler = OmniAudioGenerate(raw_request) if handler is None: - base_server = getattr(raw_request.app.state, "openai_serving_tokenization", None) + base_server = getattr(raw_request.app.state, "serving_tokenization", None) if base_server is None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND.value, From 1b0de21c552c2783da4d4330ccd07021db249e2f 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: Thu, 2 Jul 2026 22:32:05 +0800 Subject: [PATCH 002/101] [Chore]: refactor out unused/redundant params in diffusion pipelines (#1235) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Co-authored-by: Hongsheng Liu --- .../model/adding_diffusion_model.md | 2 +- docs/design/feature/cfg_parallel.md | 14 +- docs/features/custom_pipeline.md | 12 +- .../image_to_image/custom_pipeline.py | 62 +----- .../models/flux/test_flux_pipeline.py | 8 +- .../test_qwen_image_max_sequence_length.py | 15 +- .../diffusion/models/sd3/test_sd3_pipeline.py | 1 + .../wan2_2/test_wan22_pipeline_diffuse.py | 37 +++- .../diffusion/models/flux/pipeline_flux.py | 71 ++----- .../flux2_klein/pipeline_flux2_klein.py | 159 +++------------ .../longcat_image/pipeline_longcat_image.py | 76 ++----- .../pipeline_longcat_image_edit.py | 58 ++---- .../models/ovis_image/pipeline_ovis_image.py | 125 ++---------- .../models/qwen_image/pipeline_qwen_image.py | 60 ++---- .../qwen_image/pipeline_qwen_image_edit.py | 76 ++----- .../pipeline_qwen_image_edit_plus.py | 98 +++------ .../qwen_image/pipeline_qwen_image_layered.py | 92 ++------- .../diffusion/models/sd3/pipeline_sd3.py | 62 ++---- .../stable_audio/pipeline_stable_audio.py | 76 +++---- .../models/wan2_2/pipeline_wan2_2.py | 79 +++----- .../models/wan2_2/pipeline_wan2_2_i2v.py | 115 +++++------ .../models/z_image/pipeline_z_image.py | 190 ++++-------------- 22 files changed, 421 insertions(+), 1067 deletions(-) diff --git a/docs/contributing/model/adding_diffusion_model.md b/docs/contributing/model/adding_diffusion_model.md index 7ff13fd1b..6703dd9c7 100644 --- a/docs/contributing/model/adding_diffusion_model.md +++ b/docs/contributing/model/adding_diffusion_model.md @@ -382,7 +382,7 @@ def forward( for p in req.prompts ] - # Extract sampling parameters + # Extract common sampling parameters sampling_params = req.sampling_params num_inference_steps = sampling_params.num_inference_steps or 50 guidance_scale = sampling_params.guidance_scale or 7.5 diff --git a/docs/design/feature/cfg_parallel.md b/docs/design/feature/cfg_parallel.md index c73a87749..053360494 100644 --- a/docs/design/feature/cfg_parallel.md +++ b/docs/design/feature/cfg_parallel.md @@ -126,16 +126,16 @@ Call `self.diffuse` in your pipeline's forward function: ```python import torch.nn as nn +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + class YourModelPipeline(nn.Module, CFGParallelMixin): def forward( self, - prompt: str, - negative_prompt: str | None = None, - guidance_scale: float = 3.5, - num_inference_steps: int = 50, - **kwargs, - ): - # Encode prompts, Initialize latents, Get timesteps + req: DiffusionRequestBatch, + ) -> list[DiffusionOutput]: + # Read prompts and sampling params from the request + ... + # Encode prompts, initialize latents, get timesteps ... # Run diffusion loop (calls the mixin's diffuse method) latents = self.diffuse( diff --git a/docs/features/custom_pipeline.md b/docs/features/custom_pipeline.md index dc8b645e4..31334526a 100644 --- a/docs/features/custom_pipeline.md +++ b/docs/features/custom_pipeline.md @@ -54,20 +54,24 @@ Create a custom pipeline class that extends an existing pipeline. In this exampl ```python # custom_pipeline.py -from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch import torch class CustomPipeline(QwenImageEditPipeline): def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): super().__init__(od_config=od_config, prefix=prefix) - def forward(self, req, prompt=None, negative_prompt=None, **kwargs): + def forward(self, req: DiffusionRequestBatch): + # Optionally customize sampling parameters + actual_num_steps = req.sampling_params.num_inference_steps or 50 + req.sampling_params.num_inference_steps = actual_num_steps + # Call parent's forward to get normal output - output = super().forward(req=req, prompt=prompt, negative_prompt=negative_prompt, **kwargs) + output = super().forward(req=req) # Add custom trajectory data - actual_num_steps = req.sampling_params.num_inference_steps or kwargs.get('num_inference_steps', 50) output.trajectory_timesteps = torch.linspace(1000, 0, actual_num_steps, dtype=torch.float32) output.trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32) diff --git a/examples/offline_inference/custom_pipeline/image_to_image/custom_pipeline.py b/examples/offline_inference/custom_pipeline/image_to_image/custom_pipeline.py index b1c74e2b3..fb14d9d33 100644 --- a/examples/offline_inference/custom_pipeline/image_to_image/custom_pipeline.py +++ b/examples/offline_inference/custom_pipeline/image_to_image/custom_pipeline.py @@ -2,14 +2,12 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import logging -from typing import Any -import PIL.Image import torch -from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import QwenImageEditPipeline -from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch logger = logging.getLogger(__name__) @@ -18,58 +16,14 @@ class CustomPipeline(QwenImageEditPipeline): def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): super().__init__(od_config=od_config, prefix=prefix) - def forward( - self, - req: OmniDiffusionRequest, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - image: PIL.Image.Image | torch.Tensor | None = None, - true_cfg_scale: float = 4.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 1.0, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - prompt_embeds_mask: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds_mask: torch.Tensor | None = None, - output_type: str | None = "pil", - attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 512, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch): """Forward pass for image editing with dummy trajectory data.""" - # Call parent's forward to get the normal output - output = super().forward( - req=req, - prompt=prompt, - negative_prompt=negative_prompt, - image=image, - true_cfg_scale=true_cfg_scale, - height=height, - width=width, - num_inference_steps=num_inference_steps, - sigmas=sigmas, - guidance_scale=guidance_scale, - num_images_per_prompt=num_images_per_prompt, - generator=generator, - latents=latents, - prompt_embeds=prompt_embeds, - prompt_embeds_mask=prompt_embeds_mask, - negative_prompt_embeds=negative_prompt_embeds, - negative_prompt_embeds_mask=negative_prompt_embeds_mask, - output_type=output_type, - attention_kwargs=attention_kwargs, - callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs, - max_sequence_length=max_sequence_length, - ) + # Customize num_inference_steps that best suits your model + actual_num_steps = req.sampling_params.num_inference_steps or 50 + req.sampling_params.num_inference_steps = actual_num_steps - # Get actual num_inference_steps used - actual_num_steps = req.sampling_params.num_inference_steps or num_inference_steps + # Call parent's forward to get the normal output + output = super().forward(req=req) # Create dummy trajectory data dummy_trajectory_latents = torch.randn(actual_num_steps, 1, 16, 64, 64, dtype=torch.float32) diff --git a/tests/diffusion/models/flux/test_flux_pipeline.py b/tests/diffusion/models/flux/test_flux_pipeline.py index fd5d7a2ac..5a06133f4 100644 --- a/tests/diffusion/models/flux/test_flux_pipeline.py +++ b/tests/diffusion/models/flux/test_flux_pipeline.py @@ -21,6 +21,8 @@ def _make_flux_sampling(**overrides): "true_cfg_scale": 4.0, "num_outputs_per_prompt": 0, "latents": None, + "output_type": None, + "max_sequence_length": None, } values.update(overrides) return SimpleNamespace(**values) @@ -131,7 +133,7 @@ def test_forward_collates_request_prompt_tensors_for_flux(monkeypatch): "negative_prompt_embeds": negative_prompt_embeds_a, "negative_pooled_prompt_embeds": negative_pooled_prompt_embeds_a, }, - sampling_params=_make_flux_sampling(generator=gen_a, latents=latents_a), + sampling_params=_make_flux_sampling(generator=gen_a, latents=latents_a, output_type="latent"), ), SimpleNamespace( request_id="flux-prompt-b", @@ -145,12 +147,12 @@ def test_forward_collates_request_prompt_tensors_for_flux(monkeypatch): "negative_pooled_prompt_embeds": [negative_pooled_prompt_embeds_b], }, }, - sampling_params=_make_flux_sampling(generator=gen_b, latents=latents_b), + sampling_params=_make_flux_sampling(generator=gen_b, latents=latents_b, output_type="latent"), ), ] ) - outputs = pipeline.forward(batch, output_type="latent") + outputs = pipeline.forward(batch) assert encode_calls[0]["prompt"] is None assert encode_calls[0]["prompt_2"] is None diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py b/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py index 3f85a6b17..7f23b4733 100644 --- a/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py +++ b/tests/diffusion/models/qwen_image/test_qwen_image_max_sequence_length.py @@ -1,4 +1,3 @@ -import inspect from types import SimpleNamespace import pytest @@ -193,6 +192,7 @@ def _make_request_batch_prompt_sampling(**overrides): "true_cfg_scale": None, "guidance_scale_provided": False, "guidance_scale": 1.0, + "output_type": None, } values.update(overrides) return SimpleNamespace(**values) @@ -344,16 +344,3 @@ def test_qwen_edit_validator_excludes_image_placeholders_from_budget(pipeline_cl with pytest.raises(AssertionError, match="text encoder should not run"): pipeline.encode_prompt(prompt="short prompt") - - -@pytest.mark.parametrize( - "pipeline_class", - [ - QwenImagePipeline, - QwenImageLayeredPipeline, - QwenImageEditPipeline, - QwenImageEditPlusPipeline, - ], -) -def test_forward_max_sequence_length_default_is_1024(pipeline_class: type): - assert inspect.signature(pipeline_class.forward).parameters["max_sequence_length"].default == 1024 diff --git a/tests/diffusion/models/sd3/test_sd3_pipeline.py b/tests/diffusion/models/sd3/test_sd3_pipeline.py index 3164e5dda..4b0bf843f 100644 --- a/tests/diffusion/models/sd3/test_sd3_pipeline.py +++ b/tests/diffusion/models/sd3/test_sd3_pipeline.py @@ -21,6 +21,7 @@ def _make_sd3_sampling(**overrides): "generator": None, "latents": None, "guidance_scale": 4.0, + "output_type": None, } values.update(overrides) return SimpleNamespace(**values) diff --git a/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py index d695766f6..198c31908 100644 --- a/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py +++ b/tests/diffusion/models/wan2_2/test_wan22_pipeline_diffuse.py @@ -20,6 +20,12 @@ class _StubTransformer(nn.Module): return torch.float32 +class _StubTextEncoder(nn.Module): + @property + def dtype(self) -> torch.dtype: + return torch.float32 + + class _StubScheduler: def __init__(self, timesteps: list[int]) -> None: self.timesteps = torch.tensor(timesteps, dtype=torch.int64) @@ -41,12 +47,30 @@ def _noop_progress_bar(*args, **kwargs): yield _Bar() +def _stub_encode_prompt( + prompt, + negative_prompt=None, + do_classifier_free_guidance=True, + num_videos_per_prompt=1, + max_sequence_length=512, + device=None, + dtype=None, +): + del negative_prompt, do_classifier_free_guidance, device, dtype + batch_size = 1 if isinstance(prompt, str) else len(prompt) + n = batch_size * num_videos_per_prompt + hidden_size = 8 + prompt_embeds = torch.zeros(n, max_sequence_length, hidden_size) + return prompt_embeds, None + + def _make_pipeline() -> Wan22Pipeline: pipeline = object.__new__(Wan22Pipeline) nn.Module.__init__(pipeline) pipeline.device = torch.device("cpu") pipeline.transformer = _StubTransformer() pipeline.transformer_2 = None + pipeline.text_encoder = _StubTextEncoder() pipeline.transformer_config = SimpleNamespace(patch_size=(1, 2, 2), in_channels=4, out_channels=4) pipeline.scheduler = _StubScheduler([9, 5]) pipeline.od_config = SimpleNamespace(flow_shift=5.0) @@ -61,15 +85,14 @@ def _make_pipeline() -> Wan22Pipeline: pipeline._num_timesteps = None pipeline._current_timestep = None pipeline.check_inputs = lambda **kwargs: None + pipeline.encode_prompt = _stub_encode_prompt # type: ignore[method-assign] pipeline.prepare_latents = lambda **kwargs: torch.zeros((1, 4, 1, 8, 8), dtype=torch.float32) pipeline.progress_bar = _noop_progress_bar return pipeline -def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None: +def test_forward_delegates_denoising_to_diffuse() -> None: pipeline = _make_pipeline() - - prompt_embeds = torch.randn(1, 8) captured: dict[str, object] = {} def _fake_diffuse(**kwargs): @@ -86,8 +109,8 @@ def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None: width=None, num_frames=1, num_inference_steps=2, - guidance_scale_provided=False, - guidance_scale=None, + guidance_scale_provided=True, + guidance_scale=1.0, guidance_scale_2=None, boundary_ratio=None, generator=None, @@ -95,14 +118,16 @@ def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None: num_outputs_per_prompt=1, max_sequence_length=32, latents=None, + output_type="latent", extra_args={}, ), ) batch = DiffusionRequestBatch(requests=[mock_req]) - output = pipeline.forward(batch, prompt_embeds=prompt_embeds, output_type="latent", guidance_scale=1.0) + output = pipeline.forward(batch) assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8))) + assert torch.equal(captured["prompt_embeds"], torch.zeros(1, 32, 8)) assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps) assert captured["guidance_low"] == 1.0 assert captured["guidance_high"] == 1.0 diff --git a/vllm_omni/diffusion/models/flux/pipeline_flux.py b/vllm_omni/diffusion/models/flux/pipeline_flux.py index bec8d3a14..e7f97597b 100644 --- a/vllm_omni/diffusion/models/flux/pipeline_flux.py +++ b/vllm_omni/diffusion/models/flux/pipeline_flux.py @@ -502,38 +502,14 @@ class FluxPipeline( return False return True - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - prompt_2: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - negative_prompt_2: str | list[str] | None = None, - true_cfg_scale: float = 1.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 28, - sigmas: list[float] | None = None, - guidance_scale: float = 3.5, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.FloatTensor | None = None, - prompt_embeds: torch.FloatTensor | None = None, - pooled_prompt_embeds: torch.FloatTensor | None = None, - negative_prompt_embeds: torch.FloatTensor | None = None, - negative_pooled_prompt_embeds: torch.FloatTensor | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - joint_attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 512, - ) -> list[DiffusionOutput]: + def forward(self, req: DiffusionRequestBatch) -> list[DiffusionOutput]: """Forward pass for flux.""" # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. sampling_params_list = req.sampling_params_list common_sampling_params = sampling_params_list[0] - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] + if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): negative_prompt = None elif req.prompts: @@ -541,40 +517,39 @@ class FluxPipeline( height = common_sampling_params.height or self.default_sample_size * self.vae_scale_factor width = common_sampling_params.width or self.default_sample_size * self.vae_scale_factor - num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps - sigmas = common_sampling_params.sigmas or sigmas - guidance_scale = ( - common_sampling_params.guidance_scale - if common_sampling_params.guidance_scale is not None - else guidance_scale - ) - true_cfg_scale = common_sampling_params.true_cfg_scale or true_cfg_scale + num_inference_steps = common_sampling_params.num_inference_steps or 28 + sigmas = common_sampling_params.sigmas + guidance_scale = common_sampling_params.guidance_scale + true_cfg_scale = common_sampling_params.true_cfg_scale or 1.0 num_images_per_prompt = ( - common_sampling_params.num_outputs_per_prompt - if common_sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + common_sampling_params.num_outputs_per_prompt if common_sampling_params.num_outputs_per_prompt > 0 else 1 ) - generator = req.collate_request_generators(num_images_per_prompt, generator) - latents = req.collate_request_tensors("latents", latents) + max_sequence_length = common_sampling_params.max_sequence_length or 512 + generator = req.collate_request_generators(num_images_per_prompt, None) + latents = req.collate_request_tensors("latents", None) prompt_fields = DiffusionRequestBatch.collate_prompt_field_map( req.prompts, { - "prompt_embeds": prompt_embeds, - "negative_prompt_embeds": negative_prompt_embeds, - "pooled_prompt_embeds": pooled_prompt_embeds, - "negative_pooled_prompt_embeds": negative_pooled_prompt_embeds, + "prompt_embeds": None, + "negative_prompt_embeds": None, + "pooled_prompt_embeds": None, + "negative_pooled_prompt_embeds": None, }, ) prompt_embeds = prompt_fields["prompt_embeds"] negative_prompt_embeds = prompt_fields["negative_prompt_embeds"] pooled_prompt_embeds = prompt_fields["pooled_prompt_embeds"] negative_pooled_prompt_embeds = prompt_fields["negative_pooled_prompt_embeds"] + prompt_2 = None + negative_prompt_2 = None if prompt_embeds is not None: prompt = None - prompt_2 = None if negative_prompt_embeds is not None: negative_prompt = None - negative_prompt_2 = None + + output_type = common_sampling_params.output_type or "pil" + joint_attention_kwargs: dict[str, Any] | None = None + callback_on_step_end_tensor_inputs = ["latents"] # 1. Check inputs. Raise error if not correct self.check_inputs( @@ -598,9 +573,7 @@ class FluxPipeline( self._interrupt = False # 2. Define call parameters - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): + if prompt is not None: batch_size = len(prompt) else: batch_size = prompt_embeds.shape[0] diff --git a/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py b/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py index 81a6f50f1..184b5d402 100644 --- a/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py +++ b/vllm_omni/diffusion/models/flux2_klein/pipeline_flux2_klein.py @@ -750,108 +750,7 @@ class Flux2KleinPipeline( def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - image: PIL.Image.Image | list[PIL.Image.Image] | None = None, - reference_image: PIL.Image.Image | list[PIL.Image.Image] | None = None, - mask_image: PIL.Image.Image | list[PIL.Image.Image] | None = None, - prompt: str | list[str] | None = None, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - strength: float = 1.0, - guidance_scale: float | None = 4.0, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end: Callable[[int, int, dict], None] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 512, - text_encoder_out_layers: tuple[int, ...] = (9, 18, 27), - padding_mask_crop: int | None = None, - ) -> DiffusionOutput: - r""" - Function invoked when calling the pipeline for generation. - - Args: - image (`torch.Tensor`, `PIL.Image.Image`, `np.ndarray`, or list of these): - `Image`, numpy array or tensor representing an image batch to be used as the starting point. For both - numpy array and pytorch tensor, the expected value range is between `[0, 1]` If it's a tensor or a list - or tensors, the expected shape should be `(B, C, H, W)` or `(C, H, W)`. If it is a numpy array or a - list of arrays, the expected shape should be `(B, H, W, C)` or `(H, W, C)` It can also accept image - latents as `image`, but if passing latents directly it is not encoded again. - prompt (`str` or `List[str]`, *optional*): - The prompt or prompts to guide the image generation. If not defined, one has to pass `prompt_embeds`. - instead. - guidance_scale (`float`, *optional*, defaults to 4.0): - Guidance scale as defined in [Classifier-Free Diffusion - Guidance](https://huggingface.co/papers/2207.12598). `guidance_scale` is defined as `w` of equation 2. - of [Imagen Paper](https://huggingface.co/papers/2205.11487). Guidance scale is enabled by setting - `guidance_scale > 1`. Higher guidance scale encourages to generate images that are closely linked to - the text `prompt`, usually at the expense of lower image quality. For step-wise distilled models, - `guidance_scale` is ignored. - height (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor): - The height in pixels of the generated image. This is set to 1024 by default for the best results. - width (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor): - The width in pixels of the generated image. This is set to 1024 by default for the best results. - num_inference_steps (`int`, *optional*, defaults to 50): - The number of denoising steps. More denoising steps usually lead to a higher quality image at the - expense of slower inference. - sigmas (`List[float]`, *optional*): - Custom sigmas to use for the denoising process with schedulers which support a `sigmas` argument in - their `set_timesteps` method. If not defined, the default behavior when `num_inference_steps` is passed - will be used. - num_images_per_prompt (`int`, *optional*, defaults to 1): - The number of images to generate per prompt. - generator (`torch.Generator` or `List[torch.Generator]`, *optional*): - One or a list of [torch generator(s)](https://pytorch.org/docs/stable/generated/torch.Generator.html) - to make generation deterministic. - latents (`torch.Tensor`, *optional*): - Pre-generated noisy latents, sampled from a Gaussian distribution, to be used as inputs for image - generation. Can be used to tweak the same generation with different prompts. If not provided, a latents - tensor will be generated by sampling using the supplied random `generator`. - prompt_embeds (`torch.Tensor`, *optional*): - Pre-generated text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting. If not - provided, text embeddings will be generated from `prompt` input argument. - negative_prompt_embeds (`torch.Tensor`, *optional*): - Pre-generated negative text embeddings. Note that "" is used as the negative prompt in this pipeline. - If not provided, will be generated from "". - output_type (`str`, *optional*, defaults to `"pil"`): - The output format of the generate image. Choose between - [PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image` or `np.array`. - return_dict (`bool`, *optional*, defaults to `True`): - Whether or not to return a [`~pipelines.qwenimage.QwenImagePipelineOutput`] instead of a plain tuple. - attention_kwargs (`dict`, *optional*): - A kwargs dictionary that if specified is passed along to the `AttentionProcessor` as defined under - `self.processor` in - [diffusers.models.attention_processor](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/attention_processor.py). - callback_on_step_end (`Callable`, *optional*): - A function that calls at the end of each denoising steps during the inference. The function is called - with the following arguments: `callback_on_step_end(self: DiffusionPipeline, step: int, timestep: int, - callback_kwargs: Dict)`. `callback_kwargs` will include a list of all tensors as specified by - `callback_on_step_end_tensor_inputs`. - callback_on_step_end_tensor_inputs (`List`, *optional*): - The list of tensor inputs for the `callback_on_step_end` function. The tensors specified in the list - will be passed as `callback_kwargs` argument. You will only be able to include variables listed in the - `._callback_tensor_inputs` attribute of your pipeline class. - max_sequence_length (`int` defaults to 512): Maximum sequence length to use with the `prompt`. - text_encoder_out_layers (`Tuple[int]`): - Layer indices to use in the `text_encoder` to derive the final prompt embeddings. - - Examples: - - Returns: - [`~pipelines.flux2.Flux2PipelineOutput`] or `tuple`: [`~pipelines.flux2.Flux2PipelineOutput`] if - `return_dict` is True, otherwise a `tuple`. When returning a tuple, the first element is a list with the - generated images. - """ + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: if len(req.prompts) > 1: logger.warning( """This model only supports a single prompt, not a batched request.""", @@ -878,39 +777,32 @@ class Flux2KleinPipeline( else: image = PIL.Image.open(raw_image) if isinstance(raw_image, str) else cast(PIL.Image.Image, raw_image) - height = req.sampling_params.height or height - width = req.sampling_params.width or width + prompt_embeds = None + negative_prompt_embeds = None + + height = req.sampling_params.height + width = req.sampling_params.width num_inference_steps = ( - req.sampling_params.num_inference_steps - if req.sampling_params.num_inference_steps is not None - else num_inference_steps + 50 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps ) - sigmas = req.sampling_params.sigmas or sigmas - guidance_scale = ( - req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale - ) - generator = req.sampling_params.generator or generator + sigmas = req.sampling_params.sigmas + guidance_scale = req.sampling_params.guidance_scale + generator = req.sampling_params.generator num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) - max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length - text_encoder_out_layers = req.sampling_params.extra_args.get("text_encoder_out_layers", text_encoder_out_layers) + max_sequence_length = req.sampling_params.max_sequence_length or 512 + latents = req.sampling_params.latents + strength = req.sampling_params.strength if req.sampling_params.strength is not None else 1.0 - req_prompt_embeds = [p.get("prompt_embeds") if not isinstance(p, str) else None for p in req.prompts] - if any(p is not None for p in req_prompt_embeds): - # If at list one prompt is provided as an embedding, - # Then assume that the user wants to provide embeddings for all prompts, and enter this if block - # If the user in fact provides mixed input format, req_prompt_embeds will have some None's - # And `torch.stack` automatically raises an exception for us - prompt_embeds = torch.stack(req_prompt_embeds) # type: ignore # intentionally expect TypeError - - req_negative_prompt_embeds = [ - p.get("negative_prompt_embeds") if not isinstance(p, str) else None for p in req.prompts - ] - if any(p is not None for p in req_negative_prompt_embeds): - negative_prompt_embeds = torch.stack(req_negative_prompt_embeds) # type: ignore # intentionally expect TypeError + text_encoder_out_layers: tuple[int, ...] = req.sampling_params.extra_args.get( + "text_encoder_out_layers", (9, 18, 27) + ) + padding_mask_crop: int | None = req.sampling_params.extra_args.get("padding_mask_crop") + attention_kwargs: dict[str, Any] | None = None + callback_on_step_end: Callable[[int, int, dict], None] | None = None + callback_on_step_end_tensor_inputs = ["latents"] + output_type = req.sampling_params.output_type or "pil" # 1. Check inputs. Raise error if not correct self.check_inputs( @@ -930,12 +822,7 @@ class Flux2KleinPipeline( self._interrupt = False # 2. Define call parameters - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = 1 device = self._execution_device diff --git a/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image.py b/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image.py index 2884adfca..b95f59228 100644 --- a/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image.py +++ b/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image.py @@ -495,66 +495,35 @@ class LongCatImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfile noise_pred = comb_pred * scale return noise_pred - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 4.5, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.FloatTensor | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - joint_attention_kwargs: dict[str, Any] | None = None, - enable_cfg_renorm: bool | None = True, - cfg_renorm_min: float | None = 0.0, - enable_prompt_rewrite: bool | None = True, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): negative_prompt = None elif req.prompts: negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] - height = req.sampling_params.height or height or self.default_sample_size * self.vae_scale_factor - width = req.sampling_params.width or width or self.default_sample_size * self.vae_scale_factor - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps - sigmas = req.sampling_params.sigmas or sigmas - generator = req.sampling_params.generator or generator - guidance_scale = ( - req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale - ) + prompt_embeds = None + negative_prompt_embeds = None + + height = req.sampling_params.height or self.default_sample_size * self.vae_scale_factor + width = req.sampling_params.width or self.default_sample_size * self.vae_scale_factor + num_inference_steps = req.sampling_params.num_inference_steps or 50 + sigmas = req.sampling_params.sigmas + generator = req.sampling_params.generator + guidance_scale = req.sampling_params.guidance_scale num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt is not None - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) - enable_prompt_rewrite = req.sampling_params.extra_args.get("enable_prompt_rewrite", enable_prompt_rewrite) - enable_cfg_renorm = req.sampling_params.extra_args.get("enable_cfg_renorm", enable_cfg_renorm) - cfg_renorm_min = req.sampling_params.extra_args.get("cfg_renorm_min", cfg_renorm_min) + latents = req.sampling_params.latents - req_prompt_embeds = [p.get("prompt_embeds") if not isinstance(p, str) else None for p in req.prompts] - if any(p is not None for p in req_prompt_embeds): - # If at list one prompt is provided as an embedding, - # Then assume that the user wants to provide embeddings for all prompts, and enter this if block - # If the user in fact provides mixed input format, req_prompt_embeds will have some None's - # And `torch.stack` automatically raises an exception for us - prompt_embeds = torch.stack(req_prompt_embeds) # type: ignore # intentionally expect TypeError - - req_negative_prompt_embeds = [ - p.get("negative_prompt_embeds") if not isinstance(p, str) else None for p in req.prompts - ] - if any(p is not None for p in req_negative_prompt_embeds): - negative_prompt_embeds = torch.stack(req_negative_prompt_embeds) # type: ignore # intentionally expect TypeError + extra_args = getattr(req.sampling_params, "extra_args", {}) or {} + enable_prompt_rewrite: bool = extra_args.get("enable_prompt_rewrite", True) + enable_cfg_renorm: bool = extra_args.get("enable_cfg_renorm", True) + cfg_renorm_min: float = extra_args.get("cfg_renorm_min", 0.0) + joint_attention_kwargs: dict[str, Any] | None = None + output_type = req.sampling_params.output_type or "pil" self.check_inputs( prompt, @@ -571,12 +540,7 @@ class LongCatImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfile self._interrupt = False # 2. Define call parameters - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = len(prompt) device = self.device if enable_prompt_rewrite and prompt is not None: diff --git a/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image_edit.py b/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image_edit.py index 195aa89a4..4821425b0 100644 --- a/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image_edit.py +++ b/vllm_omni/diffusion/models/longcat_image/pipeline_longcat_image_edit.py @@ -6,7 +6,7 @@ import math import os import re from collections.abc import Iterable -from typing import Any, ClassVar, cast +from typing import ClassVar, cast import numpy as np import PIL.Image @@ -542,24 +542,7 @@ class LongCatImageEditPipeline( f" {negative_prompt_embeds}. Please make sure to only forward one of the two." ) - def forward( - self, - req: DiffusionRequestBatch, - image: PIL.Image.Image | torch.Tensor | None = None, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 3.5, - num_images_per_prompt: int | None = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.FloatTensor | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - joint_attention_kwargs: dict[str, Any] | None = None, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. if len(req.prompts) > 1: @@ -570,27 +553,22 @@ class LongCatImageEditPipeline( first_prompt = req.prompts[0] prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "") negative_prompt = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt") - prompt_embeds = None if isinstance(first_prompt, str) else first_prompt.get("prompt_embeds") - negative_prompt_embeds = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt_embeds") # type: ignore # Why it is list[torch.Tensor] in OmniTokenInputs or OmniEmbedsPrompt? Doesn't make sense - sigmas = req.sampling_params.sigmas or sigmas - guidance_scale = ( - req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale - ) - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps + sigmas = req.sampling_params.sigmas + guidance_scale = req.sampling_params.guidance_scale + num_inference_steps = req.sampling_params.num_inference_steps or 50 num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt is not None - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) - generator = req.sampling_params.generator or generator + generator = req.sampling_params.generator height = req.sampling_params.height or self.default_sample_size * self.vae_scale_factor width = req.sampling_params.width or self.default_sample_size * self.vae_scale_factor + latents = req.sampling_params.latents + prompt_embeds = None + negative_prompt_embeds = None + output_type = req.sampling_params.output_type or "pil" - if prompt is not None: - batch_size = 1 if isinstance(prompt, str) else len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = 1 if isinstance(prompt, str) else len(prompt) if not isinstance(first_prompt, str) and "preprocessed_image" in ( additional_information := first_prompt.get("additional_information", {}) @@ -600,13 +578,7 @@ class LongCatImageEditPipeline( calculated_height = additional_information.get("calculated_height", height) calculated_width = additional_information.get("calculated_width", width) else: - image_size = image[0].size if isinstance(image, list) else image.size - calculated_width, calculated_height = calculate_dimensions(1024 * 1024, image_size[0] * 1.0 / image_size[1]) - - if image is not None and not (isinstance(image, torch.Tensor) and image.size(1) == self.latent_channels): - image = self.image_processor.resize(image, calculated_height, calculated_width) - prompt_image = self.image_processor.resize(image, calculated_height // 2, calculated_width // 2) - image = self.image_processor.preprocess(image, calculated_height, calculated_width) + raise RuntimeError("Missing preprocess image that should have been created by the preprocess function.") self.check_inputs( prompt, @@ -621,7 +593,8 @@ class LongCatImageEditPipeline( prompt=prompt, image=prompt_image, prompt_embeds=prompt_embeds, num_images_per_prompt=num_images_per_prompt ) - if guidance_scale > 1: + do_true_cfg = guidance_scale > 1 and negative_prompt is not None + if do_true_cfg: (negative_prompt_embeds, negative_text_ids) = self.encode_prompt( prompt=negative_prompt, image=prompt_image, @@ -680,7 +653,6 @@ class LongCatImageEditPipeline( latent_model_input = torch.cat([latents, image_latents], dim=1) timestep = t.expand(latent_model_input.shape[0]).to(latents.dtype) - do_true_cfg = guidance_scale > 1 positive_kwargs = { "hidden_states": latent_model_input, "timestep": timestep / 1000, diff --git a/vllm_omni/diffusion/models/ovis_image/pipeline_ovis_image.py b/vllm_omni/diffusion/models/ovis_image/pipeline_ovis_image.py index 371af3f34..0baad965b 100644 --- a/vllm_omni/diffusion/models/ovis_image/pipeline_ovis_image.py +++ b/vllm_omni/diffusion/models/ovis_image/pipeline_ovis_image.py @@ -18,7 +18,7 @@ import inspect import json import os -from collections.abc import Callable, Iterable +from collections.abc import Iterable from typing import Any, ClassVar import numpy as np @@ -551,117 +551,33 @@ class OvisImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfilerMi def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - guidance_scale: float = 5.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - num_images_per_prompt: int | None = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.FloatTensor | None = None, - prompt_embeds: torch.FloatTensor | None = None, - negative_prompt_embeds: torch.FloatTensor | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - joint_attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end: Callable[[int, int, dict], None] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 256, - ) -> DiffusionOutput: - r""" - Function invoked when calling the pipeline for generation. - - Args: - prompt (`str` or `list[str]`, *optional*): - The prompt or prompts to guide the image generation. If not defined, one has to pass `prompt_embeds`. - instead. - negative_prompt (`str` or `list[str]`, *optional*): - The prompt or prompts not to guide the image generation. If not defined, one has to pass - `negative_prompt_embeds` instead. Ignored when not using guidance (i.e., ignored if `guidance_scale` is - not greater than `1`). - guidance_scale (`float`, *optional*, defaults to 1.0): - True classifier-free guidance (guidance scale) is enabled when `guidance_scale` > 1 and - `negative_prompt` is provided. - height (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor): - The height in pixels of the generated image. This is set to 1024 by default for the best results. - width (`int`, *optional*, defaults to self.unet.config.sample_size * self.vae_scale_factor): - The width in pixels of the generated image. This is set to 1024 by default for the best results. - num_inference_steps (`int`, *optional*, defaults to 50): - The number of denoising steps. More denoising steps usually lead to a higher quality image at the - expense of slower inference. - sigmas (`list[float]`, *optional*): - Custom sigmas to use for the denoising process with schedulers which support a `sigmas` argument in - their `set_timesteps` method. If not defined, the default behavior when `num_inference_steps` is passed - will be used. - num_images_per_prompt (`int`, *optional*, defaults to 1): - The number of images to generate per prompt. - generator (`torch.Generator` or `list[torch.Generator]`, *optional*): - One or a list of [torch generator(s)](https://pytorch.org/docs/stable/generated/torch.Generator.html) - to make generation deterministic. - latents (`torch.FloatTensor`, *optional*): - Pre-generated noisy latents, sampled from a Gaussian distribution, to be used as inputs for image - generation. Can be used to tweak the same generation with different prompts. If not provided, a latents - tensor will be generated by sampling using the supplied random `generator`. - prompt_embeds (`torch.FloatTensor`, *optional*): - Pre-generated text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting. If not - provided, text embeddings will be generated from `prompt` input argument. - negative_prompt_embeds (`torch.FloatTensor`, *optional*): - Pre-generated negative text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt - weighting. If not provided, negative_prompt_embeds will be generated from `negative_prompt` input - argument. - output_type (`str`, *optional*, defaults to `"pil"`): - The output format of the generate image. Choose between - [PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image` or `np.array`. - return_dict (`bool`, *optional*, defaults to `True`): - Whether or not to return a [`~pipelines.flux.FluxPipelineOutput`] instead of a plain tuple. - joint_attention_kwargs (`dict`, *optional*): - A kwargs dictionary that if specified is passed along to the `AttentionProcessor` as defined under - `self.processor` in - [diffusers.models.attention_processor](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/attention_processor.py). - callback_on_step_end (`Callable`, *optional*): - A function that calls at the end of each denoising steps during the inference. The function is called - with the following arguments: `callback_on_step_end(self: DiffusionPipeline, step: int, timestep: int, - callback_kwargs: dict)`. `callback_kwargs` will include a list of all tensors as specified by - `callback_on_step_end_tensor_inputs`. - callback_on_step_end_tensor_inputs (`list`, *optional*): - The list of tensor inputs for the `callback_on_step_end` function. The tensors specified in the list - will be passed as `callback_kwargs` argument. You will only be able to include variables listed in the - `._callback_tensor_inputs` attribute of your pipeline class. - max_sequence_length (`int` defaults to 512): Maximum sequence length to use with the `prompt`. - - Examples: - - Returns: - [`~pipelines.ovis_image.OvisImagePipelineOutput`] or `tuple`: - [`~pipelines.ovis_image.OvisImagePipelineOutput`] if `return_dict` is True, otherwise a `tuple`. When - returning a tuple, the first element is a list with the generated images. - """ + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): negative_prompt = None elif req.prompts: negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] + prompt_embeds = None + negative_prompt_embeds = None + height = req.sampling_params.height or self.default_sample_size * self.vae_scale_factor width = req.sampling_params.width or self.default_sample_size * self.vae_scale_factor - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps - sigmas = req.sampling_params.sigmas or sigmas - guidance_scale = ( - req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale - ) - generator = req.sampling_params.generator or generator + num_inference_steps = req.sampling_params.num_inference_steps or 50 + sigmas = req.sampling_params.sigmas + guidance_scale = req.sampling_params.guidance_scale + generator = req.sampling_params.generator num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) + latents = req.sampling_params.latents + max_sequence_length = req.sampling_params.max_sequence_length or 256 + + joint_attention_kwargs: dict[str, Any] | None = None + output_type = req.sampling_params.output_type or "pil" + callback_on_step_end_tensor_inputs = ["latents"] # Steps: # 1. Check Inputs @@ -691,12 +607,7 @@ class OvisImagePipeline(nn.Module, CFGParallelMixin, DiffusionPipelineProfilerMi self._interrupt = False # 2. Define call parameters - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = len(prompt) do_classifier_free_guidance = guidance_scale > 1.0 prompt_embeds, text_ids = self.encode_prompt( diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py index 43cd4bdc4..638316039 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py @@ -978,58 +978,34 @@ class QwenImagePipeline( height = state.sampling.height or self.default_sample_size * self.vae_scale_factor width = state.sampling.width or self.default_sample_size * self.vae_scale_factor - output_type = kwargs.get("output_type", "pil") + output_type = kwargs.get("output_type") or state.sampling.output_type or "pil" return self._decode_latents(state.latents, height, width, output_type) - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - true_cfg_scale: float = 4.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 1.0, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - prompt_embeds_mask: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds_mask: torch.Tensor | None = None, - output_type: str | None = "pil", - attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 1024, - ) -> list[DiffusionOutput]: + def forward(self, req: DiffusionRequestBatch) -> list[DiffusionOutput]: sampling_params_list = req.sampling_params_list common_sampling_params = sampling_params_list[0] extracted_prompt, negative_prompt = self._extract_prompts(req.prompts) - prompt = extracted_prompt or prompt + prompt = extracted_prompt height = common_sampling_params.height or self.default_sample_size * self.vae_scale_factor width = common_sampling_params.width or self.default_sample_size * self.vae_scale_factor height, width = normalize_min_aligned_size(height, width, self.vae_scale_factor * 2) - num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps - sigmas = common_sampling_params.sigmas or sigmas - max_sequence_length = common_sampling_params.max_sequence_length or max_sequence_length + num_inference_steps = common_sampling_params.num_inference_steps or 50 + sigmas = common_sampling_params.sigmas + max_sequence_length = common_sampling_params.max_sequence_length or 1024 num_images_per_prompt = ( - common_sampling_params.num_outputs_per_prompt - if common_sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + common_sampling_params.num_outputs_per_prompt if common_sampling_params.num_outputs_per_prompt > 0 else 1 ) - generator = req.collate_request_generators(num_images_per_prompt, generator) - latents = req.collate_request_tensors("latents", latents) + generator = req.collate_request_generators(num_images_per_prompt, None) + latents = req.collate_request_tensors("latents", None) prompt_fields = DiffusionRequestBatch.collate_prompt_field_map( req.prompts, { - "prompt_embeds": prompt_embeds, - "prompt_embeds_mask": prompt_embeds_mask, - "negative_prompt_embeds": negative_prompt_embeds, - "negative_prompt_embeds_mask": negative_prompt_embeds_mask, + "prompt_embeds": None, + "prompt_embeds_mask": None, + "negative_prompt_embeds": None, + "negative_prompt_embeds_mask": None, }, ) prompt_embeds = prompt_fields["prompt_embeds"] @@ -1040,9 +1016,16 @@ class QwenImagePipeline( prompt = None if negative_prompt_embeds is not None: negative_prompt = None - true_cfg_scale = common_sampling_params.true_cfg_scale or true_cfg_scale + true_cfg_scale = common_sampling_params.true_cfg_scale or 4.0 if common_sampling_params.guidance_scale_provided: guidance_scale = common_sampling_params.guidance_scale + else: + guidance_scale = 1.0 + + latents = req.collate_request_tensors("latents", None) + output_type = common_sampling_params.output_type or "pil" + attention_kwargs = None + callback_on_step_end_tensor_inputs = ["latents"] ctx = self._prepare_generation_context( prompt=prompt, @@ -1087,6 +1070,7 @@ class QwenImagePipeline( ) self._current_timestep = None + result = self._decode_latents(latents, height, width, output_type) return split_diffusion_output_by_request( result, diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py index e594d5ad8..3d6e937c7 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py @@ -7,7 +7,7 @@ import logging import math import os from collections.abc import Iterable -from typing import Any, ClassVar, cast +from typing import ClassVar, cast import numpy as np import PIL.Image @@ -677,30 +677,7 @@ class QwenImageEditPipeline( def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - image: PIL.Image.Image | torch.Tensor | None = None, - true_cfg_scale: float = 4.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 1.0, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - prompt_embeds_mask: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds_mask: torch.Tensor | None = None, - output_type: str | None = "pil", - attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 1024, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: """Forward pass for image editing.""" # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. @@ -731,32 +708,28 @@ class QwenImageEditPipeline( height = req.sampling_params.height width = req.sampling_params.width else: - # fallback to run pre-processing in pipeline (debug only) - image_size = image[0].size if isinstance(image, list) else image.size - calculated_width, calculated_height, _ = calculate_dimensions(1024 * 1024, image_size[0] / image_size[1]) - height = height or calculated_height - width = width or calculated_width + raise RuntimeError("Missing preprocess image that should have been created by the preprocess function.") - height, width = normalize_min_aligned_size(height, width, self.vae_scale_factor * 2) - - if image is not None and not (isinstance(image, torch.Tensor) and image.size(1) == self.latent_channels): - image = self.image_processor.resize(image, calculated_height, calculated_width) - prompt_image = image - image = self.image_processor.preprocess(image, calculated_height, calculated_width) - image = image.unsqueeze(2) - - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps - sigmas = req.sampling_params.sigmas or sigmas - max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length - generator = req.sampling_params.generator or generator - true_cfg_scale = req.sampling_params.true_cfg_scale or true_cfg_scale + num_inference_steps = req.sampling_params.num_inference_steps or 50 + sigmas = req.sampling_params.sigmas + max_sequence_length = req.sampling_params.max_sequence_length or 1024 + generator = req.sampling_params.generator + true_cfg_scale = req.sampling_params.true_cfg_scale or 4.0 if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale + else: + guidance_scale = 1.0 num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) + latents = req.sampling_params.latents + prompt_embeds = None + negative_prompt_embeds = None + prompt_embeds_mask = None + negative_prompt_embeds_mask = None + output_type = req.sampling_params.output_type or "pil" + attention_kwargs = None + callback_on_step_end_tensor_inputs = ["latents"] # 1. check inputs # 2. encode prompts @@ -783,16 +756,9 @@ class QwenImageEditPipeline( self._current_timestep = None self._interrupt = False - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = 1 - has_neg_prompt = negative_prompt is not None or ( - negative_prompt_embeds is not None and negative_prompt_embeds_mask is not None - ) + has_neg_prompt = negative_prompt is not None do_true_cfg = true_cfg_scale > 1 and has_neg_prompt self.check_cfg_parallel_validity(true_cfg_scale, has_neg_prompt) diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py index bf63805b3..a11637163 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit_plus.py @@ -5,7 +5,7 @@ import json import logging import os from collections.abc import Iterable -from typing import Any, ClassVar, cast +from typing import ClassVar, cast import numpy as np import PIL.Image @@ -623,30 +623,7 @@ class QwenImageEditPlusPipeline( def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - image: PIL.Image.Image | list[PIL.Image.Image] | torch.Tensor | None = None, - true_cfg_scale: float = 4.0, - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 1.0, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - prompt_embeds_mask: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds_mask: torch.Tensor | None = None, - output_type: str | None = "pil", - attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 1024, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: """Forward pass for image editing with support for multiple images.""" # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. @@ -674,55 +651,35 @@ class QwenImageEditPlusPipeline( ): condition_images = additional_information.get("condition_images") vae_images = additional_information.get("vae_images") - condition_image_sizes = additional_information.get("condition_image_sizes") vae_image_sizes = additional_information.get("vae_image_sizes") calculated_height = additional_information.get("calculated_height") calculated_width = additional_information.get("calculated_width") - height = req.sampling_params.height - width = req.sampling_params.width + height = req.sampling_params.height or calculated_height + width = req.sampling_params.width or calculated_width + image = None else: - # fallback to run pre-processing in pipeline (debug only) - if image is None: - raise ValueError("Image is required for QwenImageEditPlusPipeline") + raise RuntimeError("Missing preprocess images that should have been created by the preprocess function.") - if not isinstance(image, list): - image = [image] - - image_size = image[0].size - calculated_width, calculated_height = calculate_dimensions(VAE_IMAGE_SIZE, image_size[0] / image_size[1]) - height = height or calculated_height - width = width or calculated_width - - height, width = normalize_min_aligned_size(height, width, self.vae_scale_factor * 2) - - condition_images = [] - vae_images = [] - condition_image_sizes = [] - vae_image_sizes = [] - - for img in image: - image_width, image_height = img.size - condition_width, condition_height = calculate_dimensions( - CONDITION_IMAGE_SIZE, image_width / image_height - ) - vae_width, vae_height = calculate_dimensions(VAE_IMAGE_SIZE, image_width / image_height) - condition_image_sizes.append((condition_width, condition_height)) - vae_image_sizes.append((vae_width, vae_height)) - condition_images.append(self.image_processor.resize(img, condition_height, condition_width)) - vae_images.append(self.image_processor.preprocess(img, vae_height, vae_width).unsqueeze(2)) - - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps - sigmas = req.sampling_params.sigmas or sigmas - max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length - generator = req.sampling_params.generator or generator - true_cfg_scale = req.sampling_params.true_cfg_scale or true_cfg_scale + num_inference_steps = req.sampling_params.num_inference_steps or 50 + sigmas = req.sampling_params.sigmas + max_sequence_length = req.sampling_params.max_sequence_length or 1024 + generator = req.sampling_params.generator + true_cfg_scale = req.sampling_params.true_cfg_scale or 4.0 if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale + else: + guidance_scale = 1.0 num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) + latents = req.sampling_params.latents + prompt_embeds = None + negative_prompt_embeds = None + prompt_embeds_mask = None + negative_prompt_embeds_mask = None + output_type = req.sampling_params.output_type or "pil" + attention_kwargs = None + callback_on_step_end_tensor_inputs = ["latents"] # 1. check inputs # 2. encode prompts @@ -749,16 +706,9 @@ class QwenImageEditPlusPipeline( self._current_timestep = None self._interrupt = False - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = 1 - has_neg_prompt = negative_prompt is not None or ( - negative_prompt_embeds is not None and negative_prompt_embeds_mask is not None - ) + has_neg_prompt = negative_prompt is not None do_true_cfg = true_cfg_scale > 1 and has_neg_prompt self.check_cfg_parallel_validity(true_cfg_scale, has_neg_prompt) diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py index f7a9fc4b1..e2fe67976 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_layered.py @@ -7,7 +7,7 @@ import logging import math import os from collections.abc import Iterable -from typing import Any, ClassVar, cast +from typing import ClassVar, cast import numpy as np import PIL.Image @@ -648,31 +648,7 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - image: PIL.Image.Image | torch.Tensor | None = None, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - true_cfg_scale: float = 4.0, - layers: int | None = 4, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float | None = None, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - prompt_embeds_mask: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds_mask: torch.Tensor | None = None, - output_type: str | None = "pil", - attention_kwargs: dict[str, Any] | None = None, - max_sequence_length: int = 1024, - resolution: int = 640, - cfg_normalize: bool = False, - use_en_prompt: bool = False, - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: """Forward pass for image layered.""" # 1. Get preprocessed image from request (pre-processing is done in DiffusionEngine) @@ -688,26 +664,27 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "") negative_prompt = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt") - layers = req.sampling_params.layers if req.sampling_params.layers is not None else layers - resolution = req.sampling_params.resolution if req.sampling_params.resolution is not None else resolution - max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length - cfg_normalize = ( - req.sampling_params.cfg_normalize if req.sampling_params.cfg_normalize is not None else cfg_normalize - ) - use_en_prompt = ( - req.sampling_params.use_en_prompt if req.sampling_params.use_en_prompt is not None else use_en_prompt - ) - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps - sigmas = req.sampling_params.sigmas or sigmas - generator = req.sampling_params.generator or generator - true_cfg_scale = req.sampling_params.true_cfg_scale or true_cfg_scale + layers = req.sampling_params.layers + max_sequence_length = req.sampling_params.max_sequence_length or 1024 + cfg_normalize = req.sampling_params.cfg_normalize + use_en_prompt = req.sampling_params.use_en_prompt + num_inference_steps = req.sampling_params.num_inference_steps or 50 + sigmas = req.sampling_params.sigmas + generator = req.sampling_params.generator + true_cfg_scale = req.sampling_params.true_cfg_scale or 4.0 + guidance_scale = None if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) + latents = req.sampling_params.latents + prompt_embeds = None + negative_prompt_embeds = None + prompt_embeds_mask = None + negative_prompt_embeds_mask = None + output_type = req.sampling_params.output_type or "pil" + attention_kwargs = None if not isinstance(first_prompt, str) and "preprocessed_image" in ( additional_information := first_prompt.get("additional_information", {}) @@ -720,25 +697,7 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as height = req.sampling_params.height width = req.sampling_params.width else: - # fallback to run pre-processing in pipeline (debug only) - if isinstance(image, PIL.Image.Image) and image.mode != "RGBA": - image = image.convert("RGBA") - image_size = image[0].size if isinstance(image, list) else image.size - assert resolution in [640, 1024], f"resolution must be either 640 or 1024, but got {resolution}" - calculated_width, calculated_height = calculate_dimensions( - resolution * resolution, image_size[0] / image_size[1] - ) - height = calculated_height - width = calculated_width - - height, width = normalize_min_aligned_size(height, width, self.vae_scale_factor * 2) - - if image is not None and not (isinstance(image, torch.Tensor) and image.size(1) == self.latent_channels): - image = self.image_processor.resize(image, calculated_height, calculated_width) - prompt_image = image - image = self.image_processor.preprocess(image, calculated_height, calculated_width) - image = image.unsqueeze(2) - image = image.to(dtype=self.text_encoder.dtype) + raise RuntimeError("Missing preprocess image that should have been created by the preprocess function.") # 2. check inputs self.check_inputs( @@ -761,16 +720,9 @@ the image\n<|vision_start|><|image_pad|><|vision_end|><|im_end|>\n<|im_start|>as # 3. encode prompot & negative prompt if prompt is None or prompt == "" or prompt == " ": prompt = self.get_image_caption(prompt_image, use_en_prompt=use_en_prompt, device=self.device) - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = 1 - has_neg_prompt = negative_prompt is not None or ( - negative_prompt_embeds is not None and negative_prompt_embeds_mask is not None - ) + has_neg_prompt = negative_prompt is not None if true_cfg_scale > 1 and not has_neg_prompt: logger.warning( diff --git a/vllm_omni/diffusion/models/sd3/pipeline_sd3.py b/vllm_omni/diffusion/models/sd3/pipeline_sd3.py index e45614a42..b2852e524 100644 --- a/vllm_omni/diffusion/models/sd3/pipeline_sd3.py +++ b/vllm_omni/diffusion/models/sd3/pipeline_sd3.py @@ -628,57 +628,39 @@ class StableDiffusion3Pipeline(nn.Module, CFGParallelMixin, DiffusionPipelinePro return latents - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] = "", - prompt_2: str | list[str] = "", - prompt_3: str | list[str] = "", - negative_prompt: str | list[str] = "", - negative_prompt_2: str | list[str] = "", - negative_prompt_3: str | list[str] = "", - height: int | None = None, - width: int | None = None, - num_inference_steps: int = 28, - sigmas: list[float] | None = None, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - pooled_prompt_embeds: torch.Tensor | None = None, - negative_pooled_prompt_embeds: torch.Tensor | None = None, - max_sequence_length: int = 256, - ) -> list[DiffusionOutput]: + def forward(self, req: DiffusionRequestBatch) -> list[DiffusionOutput]: # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. sampling_params_list = req.sampling_params_list common_sampling_params = sampling_params_list[0] - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt - negative_prompt = [ - "" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts - ] or negative_prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] + negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] + prompt_2 = "" + prompt_3 = "" + negative_prompt_2 = "" + negative_prompt_3 = "" + prompt_embeds = None + negative_prompt_embeds = None height = common_sampling_params.height or self.default_sample_size * self.vae_scale_factor width = common_sampling_params.width or self.default_sample_size * self.vae_scale_factor - sigmas = common_sampling_params.sigmas or sigmas - max_sequence_length = common_sampling_params.max_sequence_length or max_sequence_length - num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps + sigmas = common_sampling_params.sigmas + max_sequence_length = common_sampling_params.max_sequence_length or 256 + num_inference_steps = common_sampling_params.num_inference_steps or 28 num_images_per_prompt = ( - common_sampling_params.num_outputs_per_prompt - if common_sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + common_sampling_params.num_outputs_per_prompt if common_sampling_params.num_outputs_per_prompt > 0 else 1 ) - if generator is None: - generator = req.collate_request_generators(num_images_per_prompt, generator) - latents = req.collate_request_tensors("latents", latents) + generator = req.collate_request_generators(num_images_per_prompt, None) + latents = req.collate_request_tensors("latents", None) + output_type = common_sampling_params.output_type or "pil" + prompt_fields = DiffusionRequestBatch.collate_prompt_field_map( req.prompts, { "prompt_embeds": prompt_embeds, "negative_prompt_embeds": negative_prompt_embeds, - "pooled_prompt_embeds": pooled_prompt_embeds, - "negative_pooled_prompt_embeds": negative_pooled_prompt_embeds, + "pooled_prompt_embeds": None, + "negative_pooled_prompt_embeds": None, }, ) prompt_embeds = prompt_fields["prompt_embeds"] @@ -717,9 +699,7 @@ class StableDiffusion3Pipeline(nn.Module, CFGParallelMixin, DiffusionPipelinePro self._current_timestep = None self._interrupt = False - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): + if prompt is not None: batch_size = len(prompt) else: batch_size = prompt_embeds.shape[0] @@ -771,7 +751,7 @@ class StableDiffusion3Pipeline(nn.Module, CFGParallelMixin, DiffusionPipelinePro ) self._current_timestep = None - if self.output_type == "latent": + if output_type == "latent": image = latents else: # Ensure the latents are the same dtype as the VAE for decode diff --git a/vllm_omni/diffusion/models/stable_audio/pipeline_stable_audio.py b/vllm_omni/diffusion/models/stable_audio/pipeline_stable_audio.py index 23b61209a..41f12f654 100644 --- a/vllm_omni/diffusion/models/stable_audio/pipeline_stable_audio.py +++ b/vllm_omni/diffusion/models/stable_audio/pipeline_stable_audio.py @@ -385,37 +385,21 @@ class StableAudioPipeline(nn.Module, SupportAudioOutput, SupportsComponentDiscov latents = latents * self.scheduler.init_noise_sigma return latents - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - negative_prompt: str | list[str] | None = None, - audio_end_in_s: float | None = None, - audio_start_in_s: float = 0.0, - guidance_scale: float = 7.0, - num_waveforms_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.Tensor | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - output_type: str = "np", - ) -> DiffusionOutput: + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: """ Generate audio from text prompt. Args: - req: OmniDiffusionRequest containing generation parameters - prompt: Text prompt for audio generation - negative_prompt: Negative prompt for CFG - audio_end_in_s: Audio end time in seconds (max ~47s for stable-audio-open-1.0) - audio_start_in_s: Audio start time in seconds - guidance_scale: CFG scale - num_waveforms_per_prompt: Number of audio outputs per prompt - generator: Random generator for reproducibility - latents: Pre-generated latents - prompt_embeds: Pre-computed prompt embeddings - negative_prompt_embeds: Pre-computed negative prompt embeddings - output_type: Output format ("np", "pt", or "latent") + req: OmniDiffusionRequest containing generation parameters. + The `req.sampling_params.extra_args` can include the following keys: + - audio_start_in_s (`float`, *optional*, defaults to 0.0): + Start time of the audio in seconds. + - audio_end_in_s (`float`, *optional*): + End time of the audio in seconds. + - num_waveforms_per_prompt (`int`, *optional*, defaults to 1): + Number of audio outputs per prompt. + - output_type (`str`, *optional*, defaults to "np"): + Output format ("np", "pt", or "latent"). Returns: DiffusionOutput containing generated audio @@ -423,28 +407,33 @@ class StableAudioPipeline(nn.Module, SupportAudioOutput, SupportsComponentDiscov # Extract from request # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): negative_prompt = None elif req.prompts: negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] - num_inference_steps = req.sampling_params.num_inference_steps - if num_inference_steps is None: - num_inference_steps = 100 # Default steps - num_inference_steps = req.sampling_params.num_inference_steps - if num_inference_steps is None: - num_inference_steps = 50 # Default steps + + prompt_embeds = None + negative_prompt_embeds = None + + num_inference_steps = req.sampling_params.num_inference_steps or 50 if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale + else: + guidance_scale = 7.0 - if generator is None: - generator = req.sampling_params.generator + generator = req.sampling_params.generator if generator is None and req.sampling_params.seed is not None: generator = torch.Generator(device=self.device).manual_seed(req.sampling_params.seed) + latents = req.sampling_params.latents # Get audio duration from request extra params or defaults - audio_start_in_s = req.sampling_params.extra_args.get("audio_start_in_s", audio_start_in_s) - audio_end_in_s = req.sampling_params.extra_args.get("audio_end_in_s", audio_end_in_s) + audio_start_in_s: float = req.sampling_params.extra_args.get("audio_start_in_s", 0.0) + audio_end_in_s: float | None = req.sampling_params.extra_args.get("audio_end_in_s", None) + num_waveforms_per_prompt: int = req.sampling_params.extra_args.get( + "num_waveforms_per_prompt", req.sampling_params.num_outputs_per_prompt or 1 + ) + output_type = req.sampling_params.output_type or "np" # Calculate audio length downsample_ratio = self.vae.hop_length @@ -474,12 +463,7 @@ class StableAudioPipeline(nn.Module, SupportAudioOutput, SupportsComponentDiscov ) # Determine batch size - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] + batch_size = len(prompt) device = self.device do_classifier_free_guidance = guidance_scale > 1.0 @@ -500,7 +484,7 @@ class StableAudioPipeline(nn.Module, SupportAudioOutput, SupportsComponentDiscov audio_start_in_s, audio_end_in_s, device, - do_classifier_free_guidance and (negative_prompt is not None or negative_prompt_embeds is not None), + do_classifier_free_guidance and negative_prompt is not None, batch_size, ) @@ -515,7 +499,7 @@ class StableAudioPipeline(nn.Module, SupportAudioOutput, SupportsComponentDiscov ) # Handle CFG without negative prompt - if do_classifier_free_guidance and negative_prompt_embeds is None and negative_prompt is None: + if do_classifier_free_guidance and negative_prompt is None: negative_text_audio_duration_embeds = torch.zeros_like(text_audio_duration_embeds) text_audio_duration_embeds = torch.cat( [negative_text_audio_duration_embeds, text_audio_duration_embeds], diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py index b2870549c..5d7c8a281 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py @@ -540,38 +540,27 @@ class Wan22Pipeline( return latents - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | None = None, - negative_prompt: str | None = None, - height: int = 480, - width: int = 832, - num_inference_steps: int = 40, - guidance_scale: float | tuple[float, float] = 4.0, - frame_num: int = 81, - output_type: str | None = "np", - generator: torch.Generator | list[torch.Generator] | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - attention_kwargs: dict | None = None, - **kwargs, - ) -> DiffusionOutput: - # Get parameters from request or arguments + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: + prompt: str | None = None + negative_prompt: str | None = None + prompt_embeds: torch.Tensor | None = None + negative_prompt_embeds: torch.Tensor | None = None if len(req.prompts) > 1: raise ValueError( """This model only supports a single prompt, not a batched request.""", """Please pass in a single prompt object or string, or a single-item list.""", ) - if len(req.prompts) == 1: # If req.prompt is empty, default to prompt & neg_prompt in param list - prompt = req.prompts[0] if isinstance(req.prompts[0], str) else req.prompts[0].get("prompt") - negative_prompt = None if isinstance(req.prompts[0], str) else req.prompts[0].get("negative_prompt") - if prompt is None and prompt_embeds is None: - raise ValueError("Prompt or prompt_embeds is required for Wan2.2 generation.") + if len(req.prompts) == 1: + first_prompt = req.prompts[0] + prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "") + negative_prompt = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt") - height = req.sampling_params.height or height - width = req.sampling_params.width or width - num_frames = req.sampling_params.num_frames if req.sampling_params.num_frames else frame_num + if not prompt: + raise ValueError("Prompt is required for Wan2.2 generation.") + + height = req.sampling_params.height or 480 + width = req.sampling_params.width or 832 + num_frames = req.sampling_params.num_frames or 81 # Ensure dimensions are compatible with VAE and patch size # For expand_timesteps mode, we need latent dims to be even (divisible by patch_size) @@ -579,11 +568,16 @@ class Wan22Pipeline( mod_value = self.vae_scale_factor_spatial * patch_size[1] # 16*2=32 for TI2V, 8*2=16 for I2V height = (height // mod_value) * mod_value width = (width // mod_value) * mod_value - num_steps = req.sampling_params.num_inference_steps or num_inference_steps + num_steps = 40 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps # Respect per-request guidance_scale when explicitly provided. if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale + else: + guidance_scale = 4.0 + + output_type = req.sampling_params.output_type or "np" + attention_kwargs: dict | None = None guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0] guidance_high = ( @@ -634,8 +628,7 @@ class Wan22Pipeline( dtype = self.text_encoder.dtype # Seed / generator - if generator is None: - generator = req.sampling_params.generator + generator = req.sampling_params.generator if generator is None and req.sampling_params.seed is not None: generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed) @@ -644,24 +637,16 @@ class Wan22Pipeline( current_omni_platform.synchronize() _t_pipeline_start = time.perf_counter() _t_text_enc_start = _t_pipeline_start - if prompt_embeds is None: - prompt_embeds, negative_prompt_embeds = self.encode_prompt( - prompt=prompt, - negative_prompt=negative_prompt, - do_classifier_free_guidance=guidance_low > 1.0 or guidance_high > 1.0, - num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1, - max_sequence_length=req.sampling_params.max_sequence_length or 512, - device=device, - dtype=dtype, - ) - else: - prompt_embeds = prompt_embeds.to(device=device, dtype=dtype) - if negative_prompt_embeds is not None: - negative_prompt_embeds = negative_prompt_embeds.to(device=device, dtype=dtype) - elif guidance_low > 1.0 or guidance_high > 1.0: - raise ValueError( - "negative_prompt_embeds must be provided when prompt_embeds are given and guidance > 1." - ) + prompt_embeds, negative_prompt_embeds = self.encode_prompt( + prompt=prompt, + negative_prompt=negative_prompt, + do_classifier_free_guidance=guidance_low > 1.0 or guidance_high > 1.0, + num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1, + max_sequence_length=req.sampling_params.max_sequence_length or 512, + device=device, + dtype=dtype, + ) + if DEBUG_PERF: current_omni_platform.synchronize() _t_text_enc_ms = (time.perf_counter() - _t_text_enc_start) * 1000 diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py index fc2d79cec..65f74b7f9 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py @@ -429,66 +429,59 @@ class Wan22I2VPipeline( quant_config = getattr(self.od_config, "quantization_config", None) return create_transformer_from_config(config, quant_config=quant_config) - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | None = None, - negative_prompt: str | None = None, - image: PIL.Image.Image | torch.Tensor | None = None, - height: int = 480, - width: int = 832, - num_inference_steps: int = 40, - guidance_scale: float | tuple[float, float] = 5.0, - frame_num: int = 81, - output_type: str | None = "np", - generator: torch.Generator | list[torch.Generator] | None = None, - prompt_embeds: torch.Tensor | None = None, - negative_prompt_embeds: torch.Tensor | None = None, - image_embeds: torch.Tensor | None = None, - last_image: PIL.Image.Image | torch.Tensor | None = None, - attention_kwargs: dict | None = None, - **kwargs, - ) -> DiffusionOutput: - # Get parameters from request or arguments + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: + prompt: str | None = None + negative_prompt: str | None = None + prompt_embeds: torch.Tensor | None = None + negative_prompt_embeds: torch.Tensor | None = None + image_embeds: torch.Tensor | None = None if len(req.prompts) > 1: raise ValueError( """This model only supports a single prompt, not a batched request.""", """Please pass in a single prompt object or string, or a single-item list.""", ) - if len(req.prompts) == 1: # If req.prompt is empty, default to prompt & neg_prompt in param list - prompt = req.prompts[0] if isinstance(req.prompts[0], str) else req.prompts[0].get("prompt") - negative_prompt = None if isinstance(req.prompts[0], str) else req.prompts[0].get("negative_prompt") - if prompt is None and prompt_embeds is None: - raise ValueError("Prompt or prompt_embeds is required for Wan2.2 generation.") + if len(req.prompts) == 1: + first_prompt = req.prompts[0] + prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "") + negative_prompt = None if isinstance(first_prompt, str) else first_prompt.get("negative_prompt") + + if not prompt: + raise ValueError("Prompt is required for Wan2.2 generation.") # Get image from request - if image is None: - multi_modal_data = ( - req.prompts[0].get("multi_modal_data", {}) if not isinstance(req.prompts[0], str) else None - ) - raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None - if raw_image is None: - raise ValueError("Image is required for I2V generation.") - if isinstance(raw_image, list): - if len(raw_image) > 1: - logger.warning( - """Received a list of image. Only a single image is supported by this model.""" - """Taking only the first image for now.""" - ) - raw_image = raw_image[0] - if isinstance(raw_image, str): - image = PIL.Image.open(raw_image) - else: - image = cast(PIL.Image.Image | torch.Tensor, raw_image) + multi_modal_data = req.prompts[0].get("multi_modal_data", {}) if not isinstance(req.prompts[0], str) else None + raw_image = multi_modal_data.get("image", None) if multi_modal_data is not None else None + if raw_image is None: + raise ValueError("Image is required for I2V generation.") + if isinstance(raw_image, list): + if len(raw_image) > 1: + logger.warning( + """Received a list of image. Only a single image is supported by this model.""" + """Taking only the first image for now.""" + ) + raw_image = raw_image[0] + if isinstance(raw_image, str): + image = PIL.Image.open(raw_image) + else: + image = cast(PIL.Image.Image | torch.Tensor, raw_image) - height = req.sampling_params.height or height - width = req.sampling_params.width or width - num_frames = req.sampling_params.num_frames or frame_num - num_steps = req.sampling_params.num_inference_steps or num_inference_steps + last_image: PIL.Image.Image | torch.Tensor | None = None + if multi_modal_data is not None: + last_image = multi_modal_data.get("last_image", None) + + height = req.sampling_params.height or 480 + width = req.sampling_params.width or 832 + num_frames = req.sampling_params.num_frames or 81 + num_steps = 40 if req.sampling_params.num_inference_steps is None else req.sampling_params.num_inference_steps # Respect per-request guidance_scale when explicitly provided. if req.sampling_params.guidance_scale_provided: guidance_scale = req.sampling_params.guidance_scale + else: + guidance_scale = 5.0 + + output_type = req.sampling_params.output_type or "np" + attention_kwargs: dict | None = None # Handle guidance scales guidance_low = guidance_scale if isinstance(guidance_scale, (int, float)) else guidance_scale[0] @@ -533,8 +526,7 @@ class Wan22I2VPipeline( dtype = self.transformer.dtype # Generator setup - if generator is None: - generator = req.sampling_params.generator + generator = req.sampling_params.generator if generator is None and req.sampling_params.seed is not None: generator = torch.Generator(device=device).manual_seed(req.sampling_params.seed) @@ -544,20 +536,15 @@ class Wan22I2VPipeline( _t_pipeline_start = time.perf_counter() _t_text_enc_start = _t_pipeline_start - if prompt_embeds is None: - prompt_embeds, negative_prompt_embeds = self.encode_prompt( - prompt=prompt, - negative_prompt=negative_prompt, - do_classifier_free_guidance=guidance_low > 1.0 or guidance_high > 1.0, - num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1, - max_sequence_length=req.sampling_params.max_sequence_length or 512, - device=device, - dtype=dtype, - ) - else: - prompt_embeds = prompt_embeds.to(device=device, dtype=dtype) - if negative_prompt_embeds is not None: - negative_prompt_embeds = negative_prompt_embeds.to(device=device, dtype=dtype) + prompt_embeds, negative_prompt_embeds = self.encode_prompt( + prompt=prompt, + negative_prompt=negative_prompt, + do_classifier_free_guidance=guidance_low > 1.0 or guidance_high > 1.0, + num_videos_per_prompt=req.sampling_params.num_outputs_per_prompt or 1, + max_sequence_length=req.sampling_params.max_sequence_length or 512, + device=device, + dtype=dtype, + ) if DEBUG_PERF: current_omni_platform.synchronize() diff --git a/vllm_omni/diffusion/models/z_image/pipeline_z_image.py b/vllm_omni/diffusion/models/z_image/pipeline_z_image.py index 104576f41..b77d2265c 100644 --- a/vllm_omni/diffusion/models/z_image/pipeline_z_image.py +++ b/vllm_omni/diffusion/models/z_image/pipeline_z_image.py @@ -24,7 +24,7 @@ from typing import Any, ClassVar import PIL.Image import torch import torch.nn as nn -from diffusers.image_processor import PipelineImageInput, VaeImageProcessor +from diffusers.image_processor import VaeImageProcessor from diffusers.schedulers import FlowMatchEulerDiscreteScheduler from diffusers.utils import logging from diffusers.utils.torch_utils import randn_tensor @@ -410,124 +410,21 @@ class ZImagePipeline(nn.Module, DiffusionPipelineProfilerMixin, SupportsComponen def interrupt(self): return self._interrupt - def forward( - self, - req: DiffusionRequestBatch, - prompt: str | list[str] | None = None, - image: PipelineImageInput = None, - strength: float = 0.6, - height: int = 1024, - width: int = 1024, - num_inference_steps: int = 50, - sigmas: list[float] | None = None, - guidance_scale: float = 5.0, - cfg_normalization: bool = False, - cfg_truncation: float = 1.0, - negative_prompt: str | list[str] | None = None, - num_images_per_prompt: int = 1, - generator: torch.Generator | list[torch.Generator] | None = None, - latents: torch.FloatTensor | None = None, - prompt_embeds: list[torch.FloatTensor] | None = None, - negative_prompt_embeds: list[torch.FloatTensor] | None = None, - output_type: str | None = "pil", - return_dict: bool = True, - joint_attention_kwargs: dict[str, Any] | None = None, - callback_on_step_end: Callable[[int, int, dict], None] | None = None, - callback_on_step_end_tensor_inputs: list[str] = ["latents"], - max_sequence_length: int = 512, - ) -> DiffusionOutput: - r""" - Function invoked when calling the pipeline for generation. - - Args: - prompt (`str` or `list[str]`, *optional*): - The prompt or prompts to guide the image generation. If not defined, one has to pass `prompt_embeds`. - instead. - image (`PipelineImageInput`, *optional*): - The image to use for img2img generation. If provided, the pipeline - will perform img2img instead of text-to-image. - strength (`float`, *optional*, defaults to 0.6): - Indicates extent to transform the reference `image`. Must be between 0 and 1. - height (`int`, *optional*, defaults to 1024): - The height in pixels of the generated image. - width (`int`, *optional*, defaults to 1024): - The width in pixels of the generated image. - num_inference_steps (`int`, *optional*, defaults to 50): - The number of denoising steps. More denoising steps usually lead to a higher quality image at the - expense of slower inference. - sigmas (`list[float]`, *optional*): - Custom sigmas to use for the denoising process with schedulers which support a `sigmas` argument in - their `set_timesteps` method. If not defined, the default behavior when `num_inference_steps` is passed - will be used. - guidance_scale (`float`, *optional*, defaults to 5.0): - Guidance scale as defined in [Classifier-Free Diffusion Guidance](https://arxiv.org/abs/2207.12598). - `guidance_scale` is defined as `w` of equation 2. of [Imagen - Paper](https://arxiv.org/pdf/2205.11487.pdf). Guidance scale is enabled by setting `guidance_scale > - 0`. Higher guidance scale encourages to generate images that are closely linked to the text `prompt`, - usually at the expense of lower image quality. - cfg_normalization (`bool`, *optional*, defaults to False): - Whether to apply configuration normalization. - cfg_truncation (`float`, *optional*, defaults to 1.0): - The truncation value for configuration. - negative_prompt (`str` or `list[str]`, *optional*): - The prompt or prompts not to guide the image generation. If not defined, one has to pass - `negative_prompt_embeds` instead. Ignored when not using guidance (i.e., ignored if `guidance_scale` is - less than or equal to `0`). - num_images_per_prompt (`int`, *optional*, defaults to 1): - The number of images to generate per prompt. - generator (`torch.Generator` or `list[torch.Generator]`, *optional*): - One or a list of [torch generator(s)](https://pytorch.org/docs/stable/generated/torch.Generator.html) - to make generation deterministic. - latents (`torch.FloatTensor`, *optional*): - Pre-generated noisy latents, sampled from a Gaussian distribution, to be used as inputs for image - generation. Can be used to tweak the same generation with different prompts. If not provided, a latents - tensor will be generated by sampling using the supplied random `generator`. - prompt_embeds (`list[torch.FloatTensor]`, *optional*): - Pre-generated text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt weighting. If not - provided, text embeddings will be generated from `prompt` input argument. - negative_prompt_embeds (`list[torch.FloatTensor]`, *optional*): - Pre-generated negative text embeddings. Can be used to easily tweak text inputs, *e.g.* prompt - weighting. If not provided, negative_prompt_embeds will be generated from `negative_prompt` input - argument. - output_type (`str`, *optional*, defaults to `"pil"`): - The output format of the generate image. Choose between - [PIL](https://pillow.readthedocs.io/en/stable/): `PIL.Image.Image` or `np.array`. - return_dict (`bool`, *optional*, defaults to `True`): - Whether or not to return a [`~pipelines.stable_diffusion.ZImagePipelineOutput`] instead of a plain - tuple. - joint_attention_kwargs (`dict`, *optional*): - A kwargs dictionary that if specified is passed along to the `AttentionProcessor` as defined under - `self.processor` in - [diffusers.models.attention_processor](https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/attention_processor.py). - callback_on_step_end (`Callable`, *optional*): - A function that calls at the end of each denoising steps during the inference. The function is called - with the following arguments: `callback_on_step_end(self: DiffusionPipeline, step: int, timestep: int, - callback_kwargs: Dict)`. `callback_kwargs` will include a list of all tensors as specified by - `callback_on_step_end_tensor_inputs`. - callback_on_step_end_tensor_inputs (`list`, *optional*): - The list of tensor inputs for the `callback_on_step_end` function. The tensors specified in the list - will be passed as `callback_kwargs` argument. You will only be able to include variables listed in the - `._callback_tensor_inputs` attribute of your pipeline class. - max_sequence_length (`int`, *optional*, defaults to 512): - Maximum sequence length to use with the `prompt`. - - Examples: - - Returns: - [`~pipelines.z_image.ZImagePipelineOutput`] or `tuple`: [`~pipelines.z_image.ZImagePipelineOutput`] if - `return_dict` is True, otherwise a `tuple`. When returning a tuple, the first element is a list with the - generated images. - """ + def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: # TODO: In online mode, sometimes it receives [{"negative_prompt": None}, {...}], so cannot use .get("...", "") # TODO: May be some data formatting operations on the API side. Hack for now. - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] + if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): negative_prompt = None elif req.prompts: negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] - # Handle img2img: extract image from request - if image is None and req.prompts: + prompt_embeds = None + negative_prompt_embeds = None + + image = None + if req.prompts: if len(req.prompts) > 1: logger.warning( "This model only supports a single prompt for img2img, not a batched request. " @@ -542,10 +439,8 @@ class ZImagePipeline(nn.Module, DiffusionPipelineProfilerMixin, SupportsComponen else: image = PIL.Image.open(raw_image) if isinstance(raw_image, str) else raw_image - # strength is currently only applicable for Z-Image I2I; other pipelines ignore this parameter explicit_strength = req.sampling_params.strength is not None - if explicit_strength: - strength = req.sampling_params.strength + strength = req.sampling_params.strength if explicit_strength else 0.6 if explicit_strength and image is None: logger.warning( "strength parameter (%.2f) is only applicable for image-to-image (I2I) generation. " @@ -556,20 +451,24 @@ class ZImagePipeline(nn.Module, DiffusionPipelineProfilerMixin, SupportsComponen if image is not None and strength is not None and (strength < 0 or strength > 1): raise ValueError(f"The value of strength should be in [0.0, 1.0] but is {strength}") - height = req.sampling_params.height or height - width = req.sampling_params.width or width - num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps + height = req.sampling_params.height or 1024 + width = req.sampling_params.width or 1024 + num_inference_steps = req.sampling_params.num_inference_steps or 50 generator = req.sampling_params.generator - sigmas = req.sampling_params.sigmas or sigmas - max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length - guidance_scale = ( - req.sampling_params.guidance_scale if req.sampling_params.guidance_rescale is not None else guidance_scale - ) + sigmas = req.sampling_params.sigmas + max_sequence_length = req.sampling_params.max_sequence_length or 512 + guidance_scale = req.sampling_params.guidance_scale num_images_per_prompt = ( - req.sampling_params.num_outputs_per_prompt - if req.sampling_params.num_outputs_per_prompt > 0 - else num_images_per_prompt + req.sampling_params.num_outputs_per_prompt if req.sampling_params.num_outputs_per_prompt > 0 else 1 ) + latents = req.sampling_params.latents + + cfg_normalization = req.sampling_params.cfg_normalize + cfg_truncation = req.sampling_params.extra_args.get("cfg_truncation", 1.0) + joint_attention_kwargs: dict[str, Any] | None = None + callback_on_step_end: Callable[[int, int, dict], None] | None = None + callback_on_step_end_tensor_inputs = ["latents"] + output_type = req.sampling_params.output_type or "pil" vae_scale = self.vae_scale_factor * 2 if height % vae_scale != 0: @@ -591,33 +490,20 @@ class ZImagePipeline(nn.Module, DiffusionPipelineProfilerMixin, SupportsComponen self._cfg_normalization = cfg_normalization self._cfg_truncation = cfg_truncation # 2. Define call parameters - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = len(prompt_embeds) + batch_size = len(prompt) - # If prompt_embeds is provided and prompt is None, skip encoding - if prompt_embeds is not None and prompt is None: - if self.do_classifier_free_guidance and negative_prompt_embeds is None: - raise ValueError( - "When `prompt_embeds` is provided without `prompt`, " - "`negative_prompt_embeds` must also be provided for classifier-free guidance." - ) - else: - ( - prompt_embeds, - negative_prompt_embeds, - ) = self.encode_prompt( - prompt=prompt, - negative_prompt=negative_prompt, - do_classifier_free_guidance=self.do_classifier_free_guidance, - prompt_embeds=prompt_embeds, - negative_prompt_embeds=negative_prompt_embeds, - device=device, - max_sequence_length=max_sequence_length, - ) + ( + prompt_embeds, + negative_prompt_embeds, + ) = self.encode_prompt( + prompt=prompt, + negative_prompt=negative_prompt, + do_classifier_free_guidance=self.do_classifier_free_guidance, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + device=device, + max_sequence_length=max_sequence_length, + ) # 4. Prepare latent variables num_channels_latents = self.transformer.in_channels From 9d8089095ec8c91c377dceca2cad3fde73a8d992 Mon Sep 17 00:00:00 2001 From: Jun Hee Yoo Date: Fri, 3 Jul 2026 08:29:24 +0900 Subject: [PATCH 003/101] [Bugfix] Support append/extend/count actions in TrackingArgumentParser (#4639) Signed-off-by: Junhee Yoo Co-authored-by: Claude Opus 4.8 --- tests/utils/test_tracking_parser.py | 133 +++++++++++++++++++++++++++- vllm_omni/utils/tracking_parser.py | 27 +++++- 2 files changed, 157 insertions(+), 3 deletions(-) diff --git a/tests/utils/test_tracking_parser.py b/tests/utils/test_tracking_parser.py index 437fbd50a..d2e7e4034 100644 --- a/tests/utils/test_tracking_parser.py +++ b/tests/utils/test_tracking_parser.py @@ -16,7 +16,12 @@ from vllm_omni.config.stage_config import ( StageDeployConfig, StagePipelineConfig, ) -from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace +from vllm_omni.utils.tracking_parser import ( + UNSET, + TrackingArgumentParser, + TrackingNamespace, + build_shadow_kwargs, +) ### Fake pipeline/deploy config for integration tests @@ -326,6 +331,132 @@ def test_omitted_nargs(): assert ns.items is None +### Tests for in-place mutating actions (append / extend / count). +# These actions mutate their default in place, which would crash on the bare +# ``UNSET`` sentinel. The shadow parser remaps them to a non-mutating store-style +# action (see build_shadow_kwargs in tracking_parser), so the real namespace +# still accumulates while explicit-arg tracking keeps working. +def test_append_action_omitted(): + """Omitted append args parse without error and aren't marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("--tag", action="append") + ns = p.parse_args([]) + assert ns.explicit_keys == set() + assert isinstance(ns, TrackingNamespace) + assert ns.tag is None + + +def test_append_action_explicit(): + """Repeated append args are collected and marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("--tag", action="append") + ns = p.parse_args(["--tag", "a", "--tag", "b"]) + assert ns.explicit_keys == {"tag"} + assert isinstance(ns, TrackingNamespace) + assert ns.tag == ["a", "b"] + + +def test_append_const_action_omitted(): + """Omitted append_const args aren't marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("--dbg", action="append_const", const="x", dest="flags") + ns = p.parse_args([]) + assert ns.explicit_keys == set() + assert isinstance(ns, TrackingNamespace) + assert ns.flags is None + + +def test_append_const_action_explicit(): + """Repeated append_const args accumulate the const and are explicit.""" + p = TrackingArgumentParser() + p.add_argument("--dbg", action="append_const", const="x", dest="flags") + ns = p.parse_args(["--dbg", "--dbg"]) + assert ns.explicit_keys == {"flags"} + assert isinstance(ns, TrackingNamespace) + assert ns.flags == ["x", "x"] + + +def test_extend_action_omitted(): + """Omitted extend args parse without error and aren't marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("--items", action="extend", nargs="+") + ns = p.parse_args([]) + assert ns.explicit_keys == set() + assert isinstance(ns, TrackingNamespace) + assert ns.items is None + + +def test_extend_action_explicit(): + """Repeated extend args are flattened into one list and marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("--items", action="extend", nargs="+") + ns = p.parse_args(["--items", "a", "b", "--items", "c"]) + assert ns.explicit_keys == {"items"} + assert isinstance(ns, TrackingNamespace) + assert ns.items == ["a", "b", "c"] + + +def test_count_action_omitted(): + """Omitted count args keep their default and aren't marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("-v", "--verbose", action="count", default=0) + ns = p.parse_args([]) + assert ns.explicit_keys == set() + assert isinstance(ns, TrackingNamespace) + assert ns.verbose == 0 + + +def test_count_action_explicit(): + """Repeated count flags increment and are marked explicit.""" + p = TrackingArgumentParser() + p.add_argument("-v", "--verbose", action="count", default=0) + ns = p.parse_args(["-vv"]) + assert ns.explicit_keys == {"verbose"} + assert isinstance(ns, TrackingNamespace) + assert ns.verbose == 2 + + +def test_group_append_action_explicit(): + """append args added via a group go through the same shadow-default path.""" + p = TrackingArgumentParser() + g = p.add_argument_group("TestGroup") + g.add_argument("--tag", action="append") + ns = p.parse_args(["--tag", "a", "--tag", "b"]) + assert ns.explicit_keys == {"tag"} + assert isinstance(ns, TrackingNamespace) + assert ns.tag == ["a", "b"] + + +@pytest.mark.parametrize( + ("action", "expected"), + [ + ("append", "store"), + ("extend", "store"), + ("append_const", "store_const"), + ("count", "store_const"), + ], +) +def test_build_shadow_kwargs_remaps_mutating_actions(action, expected): + """Mutating actions are remapped to a store-style action with an UNSET default.""" + shadow = build_shadow_kwargs({"action": action, "const": "c"}) + assert shadow["action"] == expected + assert shadow["default"] is UNSET + + +def test_build_shadow_kwargs_count_gets_marker_const(): + """count carries no const, so the remapped store_const gets a non-UNSET marker.""" + shadow = build_shadow_kwargs({"action": "count"}) + assert shadow["action"] == "store_const" + assert shadow["const"] is not UNSET + + +def test_build_shadow_kwargs_leaves_other_actions_untouched(): + """Non-mutating actions keep their action and only get the UNSET default.""" + shadow = build_shadow_kwargs({"action": "store_true"}) + assert shadow["action"] == "store_true" + assert shadow["default"] is UNSET + + def test_parse_known_args_tracking(): """Ensure parse_known_args is also trackable""" p = TrackingArgumentParser() diff --git a/vllm_omni/utils/tracking_parser.py b/vllm_omni/utils/tracking_parser.py index c6dc5fee7..64849a5aa 100644 --- a/vllm_omni/utils/tracking_parser.py +++ b/vllm_omni/utils/tracking_parser.py @@ -9,6 +9,29 @@ from vllm.utils.argparse_utils import FlexibleArgumentParser UNSET = object() +def build_shadow_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + """Build kwargs for the shadow argument with an ``UNSET`` default. + + Actions that mutate their default in place (append/extend/append_const/ + count) would crash on the bare ``UNSET`` sentinel, so they are remapped to + an equivalent store-style action; the shadow value only needs to flip away + from ``UNSET`` when the arg is passed explicitly. + """ + shadow_kwargs = {**kwargs, "default": UNSET} + action = kwargs.get("action") + + if action in ("append", "extend"): + shadow_kwargs["action"] = "store" + + elif action in ("append_const", "count"): + shadow_kwargs["action"] = "store_const" + + if action == "count": + shadow_kwargs["const"] = True + + return shadow_kwargs + + class TrackingNamespace(argparse.Namespace): """Proxy that wraps an argparse namespace with explicit keys, which can be filtered down to a dict containing only explicitly passed values. @@ -58,7 +81,7 @@ class TrackingGroup: def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action: """Add an argument to the real group and to the shadow group.""" action = self._real.add_argument(*args, **kwargs) - default_kwargs = {**kwargs, "default": UNSET} + default_kwargs = build_shadow_kwargs(kwargs) self._shadow.add_argument(*args, **default_kwargs) return action @@ -115,7 +138,7 @@ class TrackingArgumentParser(FlexibleArgumentParser): def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action: """Add an arg to the parser & the shadow, where the latter has UNSET for the default.""" action = super().add_argument(*args, **kwargs) - shadow_kwargs = {**kwargs, "default": UNSET} + shadow_kwargs = build_shadow_kwargs(kwargs) self._shadow.add_argument(*args, **shadow_kwargs) return action From 431a7c71ac15fc824edfe9dd59c10f7566bde2ed Mon Sep 17 00:00:00 2001 From: Sean Gillen Date: Thu, 2 Jul 2026 18:45:44 -0700 Subject: [PATCH 004/101] [Model] Cosmos3: Skip unused action conditioning frames (#4789) Signed-off-by: Sean Gillen --- .../models/cosmos3/test_cosmos3_pipeline.py | 43 +++++++++++++++++++ .../models/cosmos3/pipeline_cosmos3.py | 38 +++++++++++++--- 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index da4ae32e3..77215f79b 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -916,6 +916,49 @@ def test_prepare_latents_i2v_encodes_only_conditioning_frame(make_cosmos3_pipeli assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]], [[1.0]]]]] +@pytest.mark.parametrize("mode", ["policy", "forward_dynamics"]) +def test_prepare_action_video_latents_encodes_only_conditioning_frame(make_cosmos3_pipeline, mode: str) -> None: + pipeline = make_cosmos3_pipeline() + video = torch.zeros(1, 3, 9, 24, 32) + + latents, velocity_mask, condition_latents = pipeline._prepare_latents_action_video( + video, + mode, + 24, + 32, + 9, + torch.Generator(device="cpu").manual_seed(0), + image_size=torch.tensor([1, 3, 16, 24]), + ) + + assert pipeline.vae.encode_input_shapes == [(1, 3, 1, 24, 32)] + assert latents.shape == (1, 2, 3, 2, 3) + assert condition_latents.shape == latents.shape + torch.testing.assert_close(condition_latents[:, :, 0], torch.ones(1, 2, 2, 3)) + assert torch.count_nonzero(condition_latents[:, :, 1:]) == 0 + torch.testing.assert_close(latents[:, :, 0:1], condition_latents[:, :, 0:1]) + assert velocity_mask.tolist() == [[[[[0.0]], [[1.0]], [[1.0]]]]] + + +def test_prepare_inverse_dynamics_latents_encodes_full_video(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + video = torch.zeros(1, 3, 9, 16, 24) + + latents, velocity_mask, condition_latents = pipeline._prepare_latents_action_video( + video, + "inverse_dynamics", + 16, + 24, + 9, + torch.Generator(device="cpu").manual_seed(0), + ) + + assert pipeline.vae.encode_input_shapes == [(1, 3, 9, 16, 24)] + assert condition_latents.shape == (1, 2, 3, 2, 3) + torch.testing.assert_close(latents, condition_latents) + assert torch.count_nonzero(velocity_mask) == 0 + + def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> None: pipeline = make_cosmos3_pipeline() latents = torch.zeros(1, 2, 1, 1, 1) diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 4178115d5..3dc0d4899 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -77,6 +77,7 @@ from .action import ( normalize_action_mode, pad_action_to_dim, resolve_domain_id, + vision_condition_indexes, ) from .transfer import ( Cosmos3TransferConfig, @@ -2009,8 +2010,21 @@ class Cosmos3OmniDiffusersPipeline( generator: torch.Generator, image_size: Any | None = None, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """Prepare video latents for action modes with mode-specific conditioning.""" + """Prepare video latents for action modes with mode-specific conditioning. + + Policy and forward-dynamics modes condition only latent frame zero. + The Wan VAE is temporally causal, so encoding only the first pixel + frame preserves that latent while avoiding unused future-frame work. + Inverse dynamics conditions every latent and keeps the full encode. + """ 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 action 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 latent_hw = self._latent_hw_from_image_size(image_size) @@ -2026,12 +2040,24 @@ class Cosmos3OmniDiffusersPipeline( device=self.device, dtype=self.dtype, ) - cond_latent = self._encode_video_tensor(video_tensor, image_size=image_size) - if cond_latent.shape[2:] != noise.shape[2:]: + condition_indexes = vision_condition_indexes(mode, num_frames, self.vae_scale_factor_temporal) + condition_video = video_tensor[:, :, :1] if condition_indexes == [0] else video_tensor + cond_prefix_latent = self._encode_video_tensor(condition_video, image_size=image_size) + expected_prefix = (1, C, max(condition_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 action video latent shape mismatch: " - f"encoded={tuple(cond_latent.shape)}, expected={tuple(noise.shape)}." + f"encoded={tuple(cond_prefix_latent.shape)}, expected at least {expected_prefix}." ) + + condition_latents = torch.zeros_like(noise) + for index in condition_indexes: + condition_latents[:, :, index : index + 1] = cond_prefix_latent[:, :, index : index + 1] condition_mask = build_vision_condition_mask( mode, num_frames, @@ -2039,9 +2065,9 @@ class Cosmos3OmniDiffusersPipeline( device=self.device, dtype=self.dtype, ) - latents = condition_mask * cond_latent + (1.0 - condition_mask) * noise + latents = condition_mask * condition_latents + (1.0 - condition_mask) * noise velocity_mask = 1.0 - condition_mask - return latents, velocity_mask, cond_latent + return latents, velocity_mask, condition_latents def _prepare_action_latents( self, From f00caf35c461608695df9c265691ec49f3ebdd3b Mon Sep 17 00:00:00 2001 From: Jiaqian Liu <61532106+Celeste-jq@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:58:13 +0800 Subject: [PATCH 005/101] [image][bugfix] Fix HunyuanImage3 bridge request batching (#4849) Signed-off-by: Celeste-jq <591998922@qq.com> --- .../hunyuan_image3/test_multi_resolution.py | 20 +- .../test_hunyuan_image3_bridge.py | 40 +++- .../stage_input_processors/hunyuan_image3.py | 212 +++++++++--------- 3 files changed, 151 insertions(+), 121 deletions(-) diff --git a/tests/diffusion/models/hunyuan_image3/test_multi_resolution.py b/tests/diffusion/models/hunyuan_image3/test_multi_resolution.py index dc4d49b11..9609a39ba 100644 --- a/tests/diffusion/models/hunyuan_image3/test_multi_resolution.py +++ b/tests/diffusion/models/hunyuan_image3/test_multi_resolution.py @@ -524,11 +524,11 @@ class TestAr2DiffusionIntegration: self, token_ids: list[int], prompt_kwargs: dict[str, Any] | None = None, - ) -> tuple[Any, Any]: + ) -> dict[str, Any] | None: """Invoke ``ar2diffusion`` with a single fake AR output. - Returns ``(diffusion_inputs, ar_predicted_sizes)`` so callers can - assert on the resolved height/width. + Returns the diffusion request payload so callers can assert on the + resolved height/width. """ from vllm_omni.model_executor.stage_input_processors.hunyuan_image3 import ( ar2diffusion, @@ -601,8 +601,8 @@ class TestAr2DiffusionIntegration: token_ids=[150000, 150001, token_id], prompt_kwargs={"height": 1024, "width": 1024, "image_base_size": 1024}, ) - assert len(result) == 1 - diffusion_input = result[0] + assert result is not None + diffusion_input = result assert diffusion_input["height"] == expected_h, ( f"ratio_idx={ratio_idx}: expected height {expected_h}, got {diffusion_input['height']}" ) @@ -617,8 +617,8 @@ class TestAr2DiffusionIntegration: token_ids=[150000, 150001], # no ratio tokens prompt_kwargs={"height": 768, "width": 1024, "image_base_size": 1024}, ) - assert len(result) == 1 - diffusion_input = result[0] + assert result is not None + diffusion_input = result assert diffusion_input["height"] == 768 assert diffusion_input["width"] == 1024 @@ -633,9 +633,9 @@ class TestAr2DiffusionIntegration: token_ids=[150000], prompt_kwargs={"height": 512, "width": 512, "image_base_size": 1024}, ) - assert len(result) == 1 - assert result[0]["height"] == 512 - assert result[0]["width"] == 512 + assert result is not None + assert result["height"] == 512 + assert result["width"] == 512 # -- cache behaviour ----------------------------------------------------- diff --git a/tests/model_executor/stage_input_processors/test_hunyuan_image3_bridge.py b/tests/model_executor/stage_input_processors/test_hunyuan_image3_bridge.py index 76f3e5006..19405f28f 100644 --- a/tests/model_executor/stage_input_processors/test_hunyuan_image3_bridge.py +++ b/tests/model_executor/stage_input_processors/test_hunyuan_image3_bridge.py @@ -45,6 +45,33 @@ def test_truncate_at_cot_end_strips_tail_after_recaption_marker(): assert text == "body text" +def test_ar2diffusion_returns_one_request_payload_for_request_level_batching(): + result = ar2diffusion( + [_source_output([100], text="thought")], + prompt={"prompt": "edit"}, + ) + + assert isinstance(result, dict) + assert result["prompt"] == "edit" + + +def test_ar2diffusion_uses_parent_output_when_companions_are_present(): + result = ar2diffusion( + [ + _source_output([100], text="parent thought"), + _source_output([200], text="companion thought"), + ], + prompt={"prompt": "edit"}, + ) + + assert result is not None + assert result["extra"]["ar_generated_text"] == "parent thought" + + +def test_ar2diffusion_returns_none_without_parent_output(): + assert ar2diffusion([], prompt={"prompt": "edit"}) is None + + def test_ar2diffusion_applies_ratio_and_truncates_tail_without_tokenizer(monkeypatch: pytest.MonkeyPatch): real_import = builtins.__import__ @@ -67,10 +94,10 @@ def test_ar2diffusion_applies_ratio_and_truncates_tail_without_tokenizer(monkeyp prompt=[{"prompt": "edit", "height": 64, "width": 64}], ) - assert len(result) == 1 - assert (result[0]["height"], result[0]["width"]) == (512, 2048) - assert result[0]["extra"]["ar_generated_text"] == "decoded without special tokens" - assert "ar_token_ids" not in result[0]["extra"] + assert result is not None + assert (result["height"], result["width"]) == (512, 2048) + assert result["extra"]["ar_generated_text"] == "decoded without special tokens" + assert "ar_token_ids" not in result["extra"] def test_ar2diffusion_forwards_custom_system_prompt_body(): @@ -88,5 +115,6 @@ def test_ar2diffusion_forwards_custom_system_prompt_body(): ], ) - assert result[0]["use_system_prompt"] == "custom" - assert result[0]["system_prompt"] == marker + assert result is not None + assert result["use_system_prompt"] == "custom" + assert result["system_prompt"] == marker diff --git a/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py b/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py index 9a81a8cc3..963adb9c2 100644 --- a/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py +++ b/vllm_omni/model_executor/stage_input_processors/hunyuan_image3.py @@ -95,128 +95,130 @@ def ar2diffusion( source_outputs: list[Any], prompt: OmniTokensPrompt | TextPrompt | list | None = None, requires_multimodal_data: bool = False, -) -> list[dict[str, Any]]: +) -> dict[str, Any] | None: """Process AR stage outputs to create Diffusion stage inputs. + HunyuanImage3 produces one downstream diffusion request per parent AR + request. ``source_outputs`` may include companion outputs, but the parent + output is expected first: the orchestrator bundles diffusion inputs as + ``[parent_output, *cfg_companion_outputs]`` before invoking this bridge. + Args: prompt: Original user prompt (may contain multimodal data). requires_multimodal_data: Whether to forward multimodal data. Returns: - List of dicts, each consumable by the HunyuanImage3 diffusion pipeline. + One prompt dict consumable by the HunyuanImage3 diffusion pipeline, or + ``None`` when no parent output is available. """ - ar_outputs = source_outputs - diffusion_inputs = [] + if not source_outputs: + return None - # Normalize prompt to list - if not isinstance(prompt, list): - prompt = [prompt] if prompt is not None else [{}] + # The orchestrator constructs this list as [parent, *cfg_companions]. + ar_output = source_outputs[0] + output = ar_output.outputs[0] + generated_token_ids = output.cumulative_token_ids + # Prefer cumulative_text, fallback to text if aggregation dropped it + generated_text = getattr(output, "cumulative_text", None) or getattr(output, "text", "") or "" - for i, ar_output in enumerate(ar_outputs): - output = ar_output.outputs[0] - generated_token_ids = output.cumulative_token_ids - # Prefer cumulative_text, fallback to text if aggregation dropped it - generated_text = getattr(output, "cumulative_text", None) or getattr(output, "text", "") or "" + if isinstance(prompt, list): + original_prompt = prompt[0] if prompt else {} + elif prompt is not None: + original_prompt = prompt + else: + original_prompt = {} + if isinstance(original_prompt, dict): + pass + elif hasattr(original_prompt, "_asdict"): + original_prompt = original_prompt._asdict() + elif hasattr(original_prompt, "__dict__"): + original_prompt = vars(original_prompt) + else: + original_prompt = {} - # Get original prompt info - original_prompt = prompt[i] if i < len(prompt) else {} - if isinstance(original_prompt, dict): - pass - elif hasattr(original_prompt, "_asdict"): - original_prompt = original_prompt._asdict() - elif hasattr(original_prompt, "__dict__"): - original_prompt = vars(original_prompt) - else: - original_prompt = {} + height = original_prompt.get("height", 1024) + width = original_prompt.get("width", 1024) + text_prompt = original_prompt.get("prompt", "") + use_system_prompt = original_prompt.get("use_system_prompt") + custom_system_prompt = original_prompt.get("system_prompt") - height = original_prompt.get("height", 1024) - width = original_prompt.get("width", 1024) - text_prompt = original_prompt.get("prompt", "") - use_system_prompt = original_prompt.get("use_system_prompt") - custom_system_prompt = original_prompt.get("system_prompt") + # Prefer the AR's predicted output aspect (`` + # tail emitted by `HunyuanImage3ForCausalMM.sample` under the + # ratio-restriction logits processor) over the carried-through + # height/width, which the serving layer fills with the first + # reference image's bucket and so collapses non-square targets to + # square in the multi-image / mismatched-aspect case. Mirrors the + # official upstream where `reso_group[ratio_index]` is the + # canonical source of the diffusion target shape. + ratio_idx = _extract_ratio_index(generated_token_ids) + ar_predicted = False + if ratio_idx is not None: + base_size = int(original_prompt.get("image_base_size", 1024)) + reso_group: ResolutionGroup = get_cached_resolution_group(base_size=base_size) + try: + reso: Resolution = reso_group[ratio_idx] + height = reso.height + width = reso.width + ar_predicted = True + except IndexError: + logger.warning( + "[ar2diffusion] Request 0: ratio_index=%d out of range [0,%d), keeping prompt size %dx%d", + ratio_idx, + len(reso_group), + height, + width, + ) - # Prefer the AR's predicted output aspect (`` - # tail emitted by `HunyuanImage3ForCausalMM.sample` under the - # ratio-restriction logits processor) over the carried-through - # height/width, which the serving layer fills with the first - # reference image's bucket and so collapses non-square targets to - # square in the multi-image / mismatched-aspect case. Mirrors the - # official upstream where `reso_group[ratio_index]` is the - # canonical source of the diffusion target shape. - ratio_idx = _extract_ratio_index(generated_token_ids) - ar_predicted = False - if ratio_idx is not None: - base_size = int(original_prompt.get("image_base_size", 1024)) - reso_group: ResolutionGroup = get_cached_resolution_group(base_size=base_size) - try: - reso: Resolution = reso_group[ratio_idx] - height = reso.height - width = reso.width - ar_predicted = True - except IndexError: - logger.warning( - "[ar2diffusion] Request %d: ratio_index=%d out of range [0,%d), keeping prompt size %dx%d", - i, - ratio_idx, - len(reso_group), - height, - width, - ) + cot_text_for_dit = _truncate_at_cot_end(generated_text) - cot_text_for_dit = _truncate_at_cot_end(generated_text) + logger.info( + "[ar2diffusion] Request 0: AR generated %d tokens, text length=%d, cot_text length=%d, target size=%dx%d (%s)", + len(generated_token_ids), + len(generated_text), + len(cot_text_for_dit), + height, + width, + f"AR ratio_idx={ratio_idx}" if ar_predicted else "from prompt (no AR ratio token)", + ) - logger.info( - "[ar2diffusion] Request %d: AR generated %d tokens, text length=%d, " - "cot_text length=%d, target size=%dx%d (%s)", - i, - len(generated_token_ids), - len(generated_text), - len(cot_text_for_dit), - height, - width, - f"AR ratio_idx={ratio_idx}" if ar_predicted else "from prompt (no AR ratio token)", - ) + diffusion_input: dict[str, Any] = { + "prompt": text_prompt, + "height": height, + "width": width, + "extra": { + "ar_generated_text": cot_text_for_dit, + }, + } - diffusion_input: dict[str, Any] = { - "prompt": text_prompt, - "height": height, - "width": width, - "extra": { - "ar_generated_text": cot_text_for_dit, - }, - } + # Forward use_system_prompt so the DiT can build the same system prefix. + # Also forward the custom system prompt body when sys_type=custom so + # DiT's `get_system_prompt(use, "image", body)` doesn't fall back to + # an empty prefix and silently diverge from AR. + if use_system_prompt is not None: + diffusion_input["use_system_prompt"] = use_system_prompt + if custom_system_prompt is not None: + diffusion_input["system_prompt"] = custom_system_prompt - # Forward use_system_prompt so the DiT can build the same system prefix. - # Also forward the custom system prompt body when sys_type=custom so - # DiT's `get_system_prompt(use, "image", body)` doesn't fall back to - # an empty prefix and silently diverge from AR. - if use_system_prompt is not None: - diffusion_input["use_system_prompt"] = use_system_prompt - if custom_system_prompt is not None: - diffusion_input["system_prompt"] = custom_system_prompt + # Forward multimodal data (original image for IT2I conditioning). + # The diffusion pre_process_func reads multi_modal_data["image"], which + # matches vLLM's standard prompt schema, so we only need to pass it once. + mm_data = original_prompt.get("multi_modal_data") + if mm_data: + prompt_images = mm_data.get("image") + if prompt_images is None: + prompt_images = mm_data.get("images") + if prompt_images is not None: + diffusion_input["multi_modal_data"] = {"image": prompt_images} - # Forward multimodal data (original image for IT2I conditioning). - # The diffusion pre_process_func reads multi_modal_data["image"], which - # matches vLLM's standard prompt schema, so we only need to pass it once. - mm_data = original_prompt.get("multi_modal_data") - if mm_data: - prompt_images = mm_data.get("image") - if prompt_images is None: - prompt_images = mm_data.get("images") - if prompt_images is not None: - diffusion_input["multi_modal_data"] = {"image": prompt_images} + # Forward multimodal output from AR (if any) + if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output: + mm_output = ar_output.multimodal_output + if isinstance(mm_output, Mapping): + diffusion_input["extra"]["ar_multimodal_output"] = mm_output - # Forward multimodal output from AR (if any) - if hasattr(ar_output, "multimodal_output") and ar_output.multimodal_output: - mm_output = ar_output.multimodal_output - if isinstance(mm_output, Mapping): - diffusion_input["extra"]["ar_multimodal_output"] = mm_output + # Forward sampling params + for key in ["seed", "num_inference_steps", "guidance_scale", "negative_prompt"]: + if key in original_prompt: + diffusion_input[key] = original_prompt[key] - # Forward sampling params - for key in ["seed", "num_inference_steps", "guidance_scale", "negative_prompt"]: - if key in original_prompt: - diffusion_input[key] = original_prompt[key] - - diffusion_inputs.append(diffusion_input) - - return diffusion_inputs + return diffusion_input From 06329c64506b4a1d12a75252f98fc8bbdbbef695 Mon Sep 17 00:00:00 2001 From: Yuanheng Zhao <54058983+yuanheng-zhao@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:04:17 +0800 Subject: [PATCH 006/101] [Bugfix] Fix request batching regression on Ming-flash-omni ImageGen (#4837) Signed-off-by: Yuanheng Zhao --- .../models/ming_flash_omni/pipeline_ming_imagegen.py | 12 +++--------- vllm_omni/engine/orchestrator.py | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py b/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py index a55a79987..f3e0f3357 100644 --- a/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py +++ b/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py @@ -446,7 +446,7 @@ class MingImagePipeline(ZImagePipeline): None if ref_latent is None else tuple(ref_latent.shape), ) try: - outputs = super().forward( + outputs: DiffusionOutput = super().forward( z_req, prompt=None, height=height, @@ -462,13 +462,7 @@ class MingImagePipeline(ZImagePipeline): finally: set_forward_context_ref_latent(None) - output = outputs[0] - if hasattr(output, "output") and output.output is not None: - raw = output.output - elif hasattr(output, "images"): - raw = output.images - else: - raw = output + raw = outputs.output if not isinstance(raw, torch.Tensor): raise RuntimeError(f"ZImagePipeline returned non-tensor output: {type(raw).__name__}") if logger.isEnabledFor(logging.DEBUG): @@ -478,7 +472,7 @@ class MingImagePipeline(ZImagePipeline): raw.float().min().item(), raw.float().max().item(), ) - return DiffusionOutput(output=raw) + return outputs # ---------------------------------------------------------------------- diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index df05cb669..bdd678f27 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -1287,7 +1287,7 @@ class Orchestrator: [req_id, *self._cfg_tracker.cleanup_parent(req_id)], ) return - if already_submitted and len(diffusion_prompt) == 1: + if len(diffusion_prompt) == 1: diffusion_prompt = diffusion_prompt[0] else: diffusion_prompt = req_state.prompt From b443504d619b4a66bfcb5e6f5900e91458dbe11d Mon Sep 17 00:00:00 2001 From: CAICAII <3360776475@qq.com> Date: Fri, 3 Jul 2026 11:06:44 +0800 Subject: [PATCH 007/101] [Diffusion] Add Diffusers quantization config bridge (#3837) Signed-off-by: CAICAIIs <3360776475@qq.com> --- .../diffusers_pipeline_adapter.md | 32 ++- .../diffusers_pipeline_adapter/README.md | 32 ++- .../test_diffusers_backend.py | 224 ++++++++++++++- .../test_diffusers_quantization_real.py | 57 ++++ .../test_diffusers_quantization_utils.py | 152 ++++++++++ .../pipeline_diffusers_adapter.py | 62 +++- .../diffusers_adapter/quantization_utils.py | 266 ++++++++++++++++++ 7 files changed, 816 insertions(+), 9 deletions(-) create mode 100644 tests/diffusion/diffusion_backend/test_diffusers_quantization_real.py create mode 100644 tests/diffusion/diffusion_backend/test_diffusers_quantization_utils.py create mode 100644 vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py diff --git a/docs/user_guide/examples/online_serving/diffusers_pipeline_adapter.md b/docs/user_guide/examples/online_serving/diffusers_pipeline_adapter.md index 1adc61193..c6be3c014 100644 --- a/docs/user_guide/examples/online_serving/diffusers_pipeline_adapter.md +++ b/docs/user_guide/examples/online_serving/diffusers_pipeline_adapter.md @@ -9,7 +9,8 @@ vLLM-Omni supports running diffusion models with the diffusers backend, directly The diffusers backend is a black-box adapter. Its primary focus is to serve diffusion models online. Currently, the following features are NOT yet supported. -It is not guaranteed whether they will be supported in the future. +Community contributions are welcome to add these features when they can be +implemented with clear Diffusers or vLLM-Omni behavior. - CFG parallel execution - Sequence parallel execution @@ -66,6 +67,35 @@ This is suitable for sampling parameters not available through the vLLM-Omni int When a parameter is available in the vLLM-Omni interface, it will be adapted here. But if that parameter is simultaneously set in both the vLLM-Omni interface and `diffusers_call_kwargs`, the **former** will take precedence (because it is set at request time). +### Quantization + +Use `diffusers_load_kwargs` for Diffusers-native quantization options. If +`diffusers_load_kwargs["quantization_config"]` is provided as a dictionary, the +diffusers backend builds a Diffusers quantization config and lets Diffusers +validate it before calling `DiffusionPipeline.from_pretrained()`. + +The diffusers backend also provides a small compatibility shortcut for +vLLM-Omni quantization configs when `diffusers_load_kwargs` does not already +contain `quantization_config`. Currently this maps online/dynamic `fp8` and +online/dynamic `int8` to Diffusers/TorchAO dynamic quantization for transformer +components such as `transformer` or `transformer_2`. This path requires +`torchao` to be installed. + +For example, the CLI can request dynamic FP8 through the vLLM-Omni interface: + +```bash +vllm serve "Qwen/Qwen-Image" \ + --omni \ + --diffusion-load-format diffusers \ + --quantization-config '{"method": "fp8"}' +``` + +Other vLLM-Omni quantization methods, such as `gguf`, `modelopt`, `mxfp4`, +`mxfp8`, serialized checkpoints, static FP8 configs, and layer-name skip lists +such as `ignored_layers`, are intentionally not translated by this compatibility +shortcut. Use Diffusers-native configuration through `diffusers_load_kwargs` or +a native vLLM-Omni pipeline for those cases. + ### Attention Backends The diffusers backend converts diff --git a/examples/online_serving/diffusers_pipeline_adapter/README.md b/examples/online_serving/diffusers_pipeline_adapter/README.md index d6f41f632..37bf48aa0 100644 --- a/examples/online_serving/diffusers_pipeline_adapter/README.md +++ b/examples/online_serving/diffusers_pipeline_adapter/README.md @@ -6,7 +6,8 @@ vLLM-Omni supports running diffusion models with the diffusers backend, directly The diffusers backend is a black-box adapter. Its primary focus is to serve diffusion models online. Currently, the following features are NOT yet supported. -It is not guaranteed whether they will be supported in the future. +Community contributions are welcome to add these features when they can be +implemented with clear Diffusers or vLLM-Omni behavior. - CFG parallel execution - Sequence parallel execution @@ -68,6 +69,35 @@ But if that parameter is simultaneously set in both the vLLM-Omni interface and Consider referring to [`OmniDiffusionSamplingParams`](../../../vllm_omni/inputs/data.py) for the default sampling parameters in the vLLM-Omni interface, and the corresponding [diffusers pipeline](https://huggingface.co/docs/diffusers/main/en/api/pipelines/overview)'s `__call__` function documentation. +### Quantization + +Use `diffusers_load_kwargs` for Diffusers-native quantization options. If +`diffusers_load_kwargs["quantization_config"]` is provided as a dictionary, the +diffusers backend builds a Diffusers quantization config and lets Diffusers +validate it before calling `DiffusionPipeline.from_pretrained()`. + +The diffusers backend also provides a small compatibility shortcut for +vLLM-Omni quantization configs when `diffusers_load_kwargs` does not already +contain `quantization_config`. Currently this maps online/dynamic `fp8` and +online/dynamic `int8` to Diffusers/TorchAO dynamic quantization for transformer +components such as `transformer` or `transformer_2`. This path requires +`torchao` to be installed. + +For example, the CLI can request dynamic FP8 through the vLLM-Omni interface: + +```bash +vllm serve "Qwen/Qwen-Image" \ + --omni \ + --diffusion-load-format diffusers \ + --quantization-config '{"method": "fp8"}' +``` + +Other vLLM-Omni quantization methods, such as `gguf`, `modelopt`, `mxfp4`, +`mxfp8`, serialized checkpoints, static FP8 configs, and layer-name skip lists +such as `ignored_layers`, are intentionally not translated by this compatibility +shortcut. Use Diffusers-native configuration through `diffusers_load_kwargs` or +a native vLLM-Omni pipeline for those cases. + ### Attention Backends The diffusers backend converts diff --git a/tests/diffusion/diffusion_backend/test_diffusers_backend.py b/tests/diffusion/diffusion_backend/test_diffusers_backend.py index b9b4a72b7..babc23b4a 100644 --- a/tests/diffusion/diffusion_backend/test_diffusers_backend.py +++ b/tests/diffusion/diffusion_backend/test_diffusers_backend.py @@ -67,6 +67,50 @@ def _make_request(**overrides) -> OmniDiffusionRequest: return OmniDiffusionRequest(**defaults) +def _patch_fake_diffusers_quantization_backends(mocker): + class FakePipelineQuantizationConfig: + def __init__(self, *, quant_mapping=None, quant_backend=None, quant_kwargs=None, components_to_quantize=None): + self.quant_mapping = quant_mapping + self.quant_backend = quant_backend + self.quant_kwargs = quant_kwargs + self.components_to_quantize = components_to_quantize + + class FakeTorchAoConfig: + def __init__(self, quant_type, modules_to_not_convert=None): + self.quant_type = quant_type + self.modules_to_not_convert = modules_to_not_convert + + @classmethod + def from_dict(cls, config): + return cls(quant_type=config["quant_type"]) + + class FakeInt8DynamicActivationInt8WeightConfig: + pass + + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.quantization_utils.PipelineQuantizationConfig", + FakePipelineQuantizationConfig, + ) + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.quantization_utils.TorchAoConfig", + FakeTorchAoConfig, + ) + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.quantization_utils._get_torchao_quant_type_cls", + return_value=FakeInt8DynamicActivationInt8WeightConfig, + ) + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.quantization_utils._get_diffusers_quantization_config_cls", + return_value=FakeTorchAoConfig, + ) + return FakePipelineQuantizationConfig, FakeTorchAoConfig, FakeInt8DynamicActivationInt8WeightConfig + + +class _TransformerBackedPipeline(DiffusionPipeline): + def __init__(self, transformer): + pass + + def _make_batch(**overrides) -> DiffusionRequestBatch: """Wrap a single request in a DiffusionRequestBatch, matching the forward contract.""" return DiffusionRequestBatch(requests=[_make_request(**overrides)]) @@ -98,7 +142,7 @@ class TestPipelineArgumentsHandling: @pytest.mark.parametrize( "feature_id", - ["cfg_parallel", "ulysses", "ring", "teacache", "cache_dit", "enforce_eager", "quantization"], + ["cfg_parallel", "ulysses", "ring", "teacache", "cache_dit", "enforce_eager", "unsupported_quantization"], ) def test_adapter_guard_unsupported_feature(self, feature_id): if feature_id == "cfg_parallel": @@ -128,14 +172,188 @@ class TestPipelineArgumentsHandling: ) elif feature_id == "enforce_eager": od_config = _make_od_config(enforce_eager=True) - elif feature_id == "quantization": - od_config = _make_od_config(quantization_config=SimpleNamespace(quant_method="fp8")) + elif feature_id == "unsupported_quantization": + od_config = _make_od_config(quantization_config=SimpleNamespace(quant_method="gguf")) else: raise ValueError(f"Unknown feature ID: {feature_id}") with pytest.raises(NotImplementedError): DiffusersAdapterPipeline(od_config=od_config) + def test_adapter_load_weights_injects_supported_quantization_config(self, mocker): + ( + FakePipelineQuantizationConfig, + FakeTorchAoConfig, + FakeInt8DynamicActivationInt8WeightConfig, + ) = _patch_fake_diffusers_quantization_backends(mocker) + + class MockPipeline: + def __call__(self, prompt=None): + return None + + def to(self, device): + return self + + mock_from_pretrained = mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained", + return_value=MockPipeline(), + ) + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.load_config", + return_value={ + "_class_name": "TransformerBackedPipeline", + "scheduler": ["diffusers", "SchedulerMixin"], + "transformer": ["diffusers", "Transformer2DModel"], + "transformer_2": ["diffusers", "Transformer2DModel"], + "vae": ["diffusers", "AutoencoderKL"], + }, + ) + + adapter = DiffusersAdapterPipeline( + od_config=_make_od_config( + quantization_config=SimpleNamespace(quant_method="int8"), + diffusers_pipeline_cls=_TransformerBackedPipeline, + ) + ) + adapter.load_weights() + + pipeline_quant_config = mock_from_pretrained.call_args.kwargs["quantization_config"] + torchao_config = pipeline_quant_config.quant_mapping["transformer"] + assert isinstance(pipeline_quant_config, FakePipelineQuantizationConfig) + assert isinstance(torchao_config, FakeTorchAoConfig) + assert isinstance(torchao_config.quant_type, FakeInt8DynamicActivationInt8WeightConfig) + assert isinstance( + pipeline_quant_config.quant_mapping["transformer_2"].quant_type, + FakeInt8DynamicActivationInt8WeightConfig, + ) + + @pytest.mark.parametrize( + ("pipeline_config", "diffusers_load_kwargs", "diffusers_pipeline_cls"), + [ + ( + { + "_class_name": "NoTransformerPipeline", + "scheduler": ["diffusers", "SchedulerMixin"], + "unet": ["diffusers", "UNet2DConditionModel"], + "vae": ["diffusers", "AutoencoderKL"], + }, + {}, + _TransformerBackedPipeline, + ), + ( + { + "_class_name": "TransformerPipeline", + "scheduler": ["diffusers", "SchedulerMixin"], + "transformer": ["diffusers", "Transformer2DModel"], + "vae": ["diffusers", "AutoencoderKL"], + }, + {"transformer": None}, + None, + ), + ], + ) + def test_adapter_load_weights_rejects_converted_quantization_without_transformer_component( + self, + pipeline_config, + diffusers_load_kwargs, + diffusers_pipeline_cls, + mocker, + ): + _patch_fake_diffusers_quantization_backends(mocker) + mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.load_config", + return_value=pipeline_config, + ) + mock_from_pretrained = mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained" + ) + + adapter = DiffusersAdapterPipeline( + od_config=_make_od_config( + quantization_config=SimpleNamespace(quant_method="int8"), + diffusers_load_kwargs=diffusers_load_kwargs, + diffusers_pipeline_cls=diffusers_pipeline_cls, + ) + ) + + with pytest.raises(NotImplementedError, match="transformer"): + adapter.load_weights() + mock_from_pretrained.assert_not_called() + + def test_adapter_rejects_dduf_file_with_converted_quantization(self): + with pytest.raises(NotImplementedError, match="dduf_file"): + DiffusersAdapterPipeline( + od_config=_make_od_config( + quantization_config=SimpleNamespace(quant_method="int8"), + diffusers_load_kwargs={"dduf_file": "model.dduf"}, + ) + ) + + def test_adapter_load_weights_preserves_diffusers_native_quantization_config(self, mocker): + class MockPipeline: + def __call__(self, prompt=None): + return None + + def to(self, device): + return self + + diffusers_native_quantization_config = object() + mock_from_pretrained = mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained", + return_value=MockPipeline(), + ) + + adapter = DiffusersAdapterPipeline( + od_config=_make_od_config( + quantization_config=SimpleNamespace(quant_method="gguf"), + diffusers_load_kwargs={ + "dduf_file": "model.dduf", + "quantization_config": diffusers_native_quantization_config, + }, + ) + ) + adapter.load_weights() + + kwargs = mock_from_pretrained.call_args.kwargs + assert kwargs["quantization_config"] is diffusers_native_quantization_config + assert kwargs["dduf_file"] == "model.dduf" + + def test_adapter_load_weights_builds_diffusers_native_quantization_config_from_dict(self, mocker): + FakePipelineQuantizationConfig, FakeTorchAoConfig, _ = _patch_fake_diffusers_quantization_backends(mocker) + + class MockPipeline: + def __call__(self, prompt=None): + return None + + def to(self, device): + return self + + mock_from_pretrained = mocker.patch( + "vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter.DiffusionPipeline.from_pretrained", + return_value=MockPipeline(), + ) + + adapter = DiffusersAdapterPipeline( + od_config=_make_od_config( + diffusers_load_kwargs={ + "quantization_config": { + "quant_mapping": { + "transformer": { + "quant_method": "torchao", + "quant_type": "fake-int8-config", + }, + }, + }, + }, + ) + ) + adapter.load_weights() + + quant_config = mock_from_pretrained.call_args.kwargs["quantization_config"] + assert isinstance(quant_config, FakePipelineQuantizationConfig) + assert isinstance(quant_config.quant_mapping["transformer"], FakeTorchAoConfig) + assert quant_config.quant_mapping["transformer"].quant_type == "fake-int8-config" + def test_adapter_guard_unknown_output_type(self, mocker): """Test that the adapter wraps an unknown output type as-is. This is useful when `return_dict=True` and the diffusers pipeline returns an OrderedDict subclass.""" diff --git a/tests/diffusion/diffusion_backend/test_diffusers_quantization_real.py b/tests/diffusion/diffusion_backend/test_diffusers_quantization_real.py new file mode 100644 index 000000000..5faa42eaf --- /dev/null +++ b/tests/diffusion/diffusion_backend/test_diffusers_quantization_real.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Optional real-backend checks for Diffusers quantization config conversion.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +def _load_quantization_utils(): + module_path = ( + Path(__file__).resolve().parents[3] / "vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py" + ) + spec = importlib.util.spec_from_file_location("diffusers_quantization_utils_real_test", module_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_convert_diffusers_quantization_config_from_real_torchao_dict(): + diffusers = pytest.importorskip("diffusers") + torchao_quantization = pytest.importorskip("torchao.quantization") + quant_type_cls = getattr(torchao_quantization, "Int8DynamicActivationInt8WeightConfig", None) + if quant_type_cls is None: + pytest.skip("torchao does not expose Int8DynamicActivationInt8WeightConfig") + + quantization_utils = _load_quantization_utils() + + real_torchao_config = diffusers.TorchAoConfig(quant_type=quant_type_cls()) + serialized_torchao_config = real_torchao_config.to_dict() + assert serialized_torchao_config["quant_type"].keys() == {"default"} + + load_kwargs = { + "quantization_config": { + "quant_mapping": { + "transformer": serialized_torchao_config, + }, + }, + } + + quantization_utils.convert_diffusers_quantization_config(load_kwargs) + + quant_config = load_kwargs["quantization_config"] + component_config = quant_config.quant_mapping["transformer"] + assert isinstance(quant_config, diffusers.PipelineQuantizationConfig) + assert isinstance(component_config, diffusers.TorchAoConfig) + assert isinstance( + component_config.quant_type, + quant_type_cls, + ) diff --git a/tests/diffusion/diffusion_backend/test_diffusers_quantization_utils.py b/tests/diffusion/diffusion_backend/test_diffusers_quantization_utils.py new file mode 100644 index 000000000..6def7ebad --- /dev/null +++ b/tests/diffusion/diffusion_backend/test_diffusers_quantization_utils.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Focused tests for Diffusers backend quantization conversion helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from vllm_omni.diffusion.models.diffusers_adapter import quantization_utils + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +class _FakePipelineQuantizationConfig: + def __init__(self, *, quant_mapping=None, quant_backend=None, quant_kwargs=None, components_to_quantize=None): + self.quant_mapping = quant_mapping + self.quant_backend = quant_backend + self.quant_kwargs = quant_kwargs + self.components_to_quantize = components_to_quantize + + +class _FakeTorchAoConfig: + def __init__(self, quant_type): + self.quant_type = quant_type + + @classmethod + def from_dict(cls, config): + return cls(quant_type=config["quant_type"]) + + +class _FakeFloat8DynamicActivationFloat8WeightConfig: + pass + + +class _FakeInt8DynamicActivationInt8WeightConfig: + pass + + +@pytest.fixture(autouse=True) +def patch_quantization_backends(monkeypatch): + monkeypatch.setattr( + quantization_utils, + "PipelineQuantizationConfig", + _FakePipelineQuantizationConfig, + ) + monkeypatch.setattr(quantization_utils, "TorchAoConfig", _FakeTorchAoConfig) + + def fake_get_torchao_quant_type_cls(class_name: str): + return { + "Float8DynamicActivationFloat8WeightConfig": _FakeFloat8DynamicActivationFloat8WeightConfig, + "Int8DynamicActivationInt8WeightConfig": _FakeInt8DynamicActivationInt8WeightConfig, + }[class_name] + + monkeypatch.setattr(quantization_utils, "_get_torchao_quant_type_cls", fake_get_torchao_quant_type_cls) + monkeypatch.setattr(quantization_utils, "_get_diffusers_quantization_config_cls", lambda _: _FakeTorchAoConfig) + + +def _quant_config(method: str, **kwargs): + return SimpleNamespace(get_name=lambda: method, **kwargs) + + +@pytest.mark.parametrize( + ("method", "expected_type"), + [ + ("fp8", _FakeFloat8DynamicActivationFloat8WeightConfig), + ("int8", _FakeInt8DynamicActivationInt8WeightConfig), + ], +) +def test_apply_injects_converted_quantization_config(method, expected_type): + od_config = SimpleNamespace(quantization_config=_quant_config(method)) + load_kwargs = {} + + injected = quantization_utils.apply_diffusers_quantization_config( + od_config, + load_kwargs, + {"transformer": ["diffusers", "Transformer2DModel"]}, + ) + + assert injected is True + torchao_config = load_kwargs["quantization_config"].quant_mapping["transformer"] + assert isinstance(torchao_config.quant_type, expected_type) + + +def test_apply_injects_converted_quantization_config_for_transformer_2(): + od_config = SimpleNamespace(quantization_config=_quant_config("int8")) + load_kwargs = {} + + quantization_utils.apply_diffusers_quantization_config( + od_config, + load_kwargs, + { + "transformer": ["diffusers", "Transformer2DModel"], + "transformer_2": ["diffusers", "Transformer2DModel"], + }, + ) + + assert sorted(load_kwargs["quantization_config"].quant_mapping) == ["transformer", "transformer_2"] + + +@pytest.mark.parametrize("method", ["gguf", "modelopt", "mxfp4", "mxfp8", "inc"]) +def test_unsupported_methods_fail_explicitly(method): + with pytest.raises(NotImplementedError, match=method): + quantization_utils.ensure_supported_diffusers_quantization(_quant_config(method)) + + +@pytest.mark.parametrize( + "quant_config", + [ + _quant_config("fp8", activation_scheme="static"), + _quant_config("int8", is_checkpoint_int8_serialized=True), + _quant_config("fp8", weight_block_size=[128, 128]), + _quant_config("int8", ignored_layers=["transformer.proj_out"]), + ], +) +def test_ambiguous_mappings_fail_explicitly(quant_config): + with pytest.raises(NotImplementedError): + quantization_utils.ensure_supported_diffusers_quantization(quant_config) + + +def test_apply_preserves_diffusers_load_kwargs_quantization_config(mocker): + od_config = SimpleNamespace(quantization_config=_quant_config("fp8")) + existing = object() + load_kwargs = {"quantization_config": existing} + mock_warning = mocker.patch.object(quantization_utils.logger, "warning") + + injected = quantization_utils.apply_diffusers_quantization_config(od_config, load_kwargs, {}) + + assert injected is False + assert load_kwargs["quantization_config"] is existing + assert "Using the Diffusers-native quantization_config" in mock_warning.call_args.args[0] + + +def test_convert_diffusers_quantization_config_from_dict(): + load_kwargs = { + "quantization_config": { + "quant_mapping": { + "transformer": { + "quant_method": "torchao", + "quant_type": "fake-int8-config", + } + } + } + } + + quantization_utils.convert_diffusers_quantization_config(load_kwargs) + + quant_config = load_kwargs["quantization_config"] + assert isinstance(quant_config, _FakePipelineQuantizationConfig) + assert isinstance(quant_config.quant_mapping["transformer"], _FakeTorchAoConfig) + assert quant_config.quant_mapping["transformer"].quant_type == "fake-int8-config" diff --git a/vllm_omni/diffusion/models/diffusers_adapter/pipeline_diffusers_adapter.py b/vllm_omni/diffusion/models/diffusers_adapter/pipeline_diffusers_adapter.py index 4d48053a6..94fcb6699 100644 --- a/vllm_omni/diffusion/models/diffusers_adapter/pipeline_diffusers_adapter.py +++ b/vllm_omni/diffusion/models/diffusers_adapter/pipeline_diffusers_adapter.py @@ -24,6 +24,11 @@ from torch import nn from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.models.diffusers_adapter.pipeline_utils import BasePipelineUtils, get_pipeline_utils +from vllm_omni.diffusion.models.diffusers_adapter.quantization_utils import ( + apply_diffusers_quantization_config, + convert_diffusers_quantization_config, + ensure_supported_diffusers_quantization, +) from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from vllm_omni.inputs.data import OmniPromptType, OmniTextPrompt @@ -31,6 +36,20 @@ from vllm_omni.platforms import current_omni_platform logger = logging.getLogger(__name__) +_DIFFUSERS_CONFIG_LOAD_KWARGS = { + "cache_dir", + "dduf_entries", + "force_download", + "local_dir", + "local_dir_use_symlinks", + "local_files_only", + "proxies", + "revision", + "subfolder", + "token", + "user_agent", +} + class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin): """Black-box adapter that delegates full pipeline execution to a diffusers pipeline. @@ -80,12 +99,19 @@ class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin): "torch_dtype": dtype, **self.od_config.diffusers_load_kwargs, } - logger.debug(f"Loading diffusers pipeline with kwargs: {load_kwargs}") + convert_diffusers_quantization_config(load_kwargs) pipeline_class = self.od_config.diffusers_pipeline_cls pipeline_class_name = pipeline_class.__name__ if pipeline_class is not None else None self._pipeline_utils = get_pipeline_utils(pipeline_class_name) self._pipeline_utils.update_load_kwargs(self.od_config, load_kwargs) + component_names = ( + self._load_diffusers_component_names(model_id, load_kwargs) + if self.od_config.quantization_config is not None and "quantization_config" not in load_kwargs + else {} + ) + apply_diffusers_quantization_config(self.od_config, load_kwargs, component_names) + logger.debug(f"Loading diffusers pipeline with kwargs: {load_kwargs}") self._pipeline = DiffusionPipeline.from_pretrained(model_id, **load_kwargs) self._pipeline_utils.apply_post_load_updates(self._pipeline, self.od_config) @@ -190,10 +216,38 @@ class DiffusersAdapterPipeline(nn.Module, DiffusionPipelineProfilerMixin): "Eager execution is not supported with the diffusers backend. " "Use a native pipeline for continuous batching mode." ) - if self.od_config.quantization_config is not None: - raise NotImplementedError( - "Quantization is not supported with the diffusers backend. Use a native pipeline for quantization." + if ( + self.od_config.quantization_config is not None + and "quantization_config" not in self.od_config.diffusers_load_kwargs + ): + ensure_supported_diffusers_quantization(self.od_config.quantization_config) + if self.od_config.diffusers_load_kwargs.get("dduf_file"): + raise NotImplementedError( + "Diffusers backend quantization conversion does not support " + "diffusers_load_kwargs.dduf_file yet. The preflight component " + "discovery would need to mirror Diffusers' DDUF config loading. " + "Use diffusers_load_kwargs.quantization_config for a native " + "Diffusers quantization config, or omit dduf_file for vLLM-Omni " + "quantization conversion." + ) + + def _load_diffusers_component_names( + self, + model_id: str, + load_kwargs: dict[str, Any], + ) -> dict[str, Any]: + config_load_kwargs = {k: load_kwargs[k] for k in _DIFFUSERS_CONFIG_LOAD_KWARGS if k in load_kwargs} + pipeline_config = DiffusionPipeline.load_config(model_id, **config_load_kwargs) + return { + name: value + for name, value in pipeline_config.items() + if ( + isinstance(value, list) + and len(value) > 0 + and value[0] is not None + and (name not in load_kwargs or load_kwargs[name] is not None) ) + } # ------------------------------------------------------------------ # Wrap settings, inputs, and outputs diff --git a/vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py b/vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py new file mode 100644 index 000000000..a7228d21e --- /dev/null +++ b/vllm_omni/diffusion/models/diffusers_adapter/quantization_utils.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Quantization helpers for the Diffusers backend.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from typing import Any + +from diffusers import PipelineQuantizationConfig, TorchAoConfig + +logger = logging.getLogger(__name__) + +_DIFFUSERS_TRANSFORMER_COMPONENTS = ("transformer", "transformer_2") +_QuantizationConfigSpec = tuple[Callable[[Any], None], str] + + +def ensure_supported_diffusers_quantization(quant_config: Any) -> None: + """Validate that a vLLM-Omni quantization config has a Diffusers mapping.""" + + method = _get_quant_method_name(quant_config) + validator, _ = _get_quantization_config_spec(method) + validator(quant_config) + + +def convert_diffusers_quantization_config(load_kwargs: dict[str, Any]) -> None: + """Build Diffusers-native quantization config objects from dict kwargs.""" + + quant_config = load_kwargs.get("quantization_config") + if isinstance(quant_config, dict): + load_kwargs["quantization_config"] = _build_diffusers_native_quantization_config(quant_config) + + +def apply_diffusers_quantization_config( + od_config: Any, + load_kwargs: dict[str, Any], + component_names: dict[str, Any], +) -> bool: + """Inject a courtesy-converted quantization_config into load kwargs. + + ``diffusers_load_kwargs`` is the canonical Diffusers backend configuration + path, so an explicit ``quantization_config`` already present in ``load_kwargs`` + is never replaced. Returns whether a config was injected by this helper. + """ + + quant_config = getattr(od_config, "quantization_config", None) + if quant_config is None: + return False + + if "quantization_config" in load_kwargs: + logger.warning( + "Both vLLM-Omni quantization_config and diffusers_load_kwargs.quantization_config " + "were provided for the diffusers backend. Using the Diffusers-native " + "quantization_config from diffusers_load_kwargs." + ) + return False + + transformer_components = _get_diffusers_transformer_components(component_names) + load_kwargs["quantization_config"] = _build_diffusers_quantization_config( + quant_config, + transformer_components, + ) + return True + + +def _normalize_method_name(method: Any) -> str: + method = getattr(method, "value", method) + return str(method).lower().replace("-", "_") + + +def _get_quant_method_name(quant_config: Any) -> str: + get_name = getattr(quant_config, "get_name", None) + if get_name is not None: + return _normalize_method_name(get_name() if callable(get_name) else get_name) + + method = getattr(quant_config, "quant_method", None) + if method is None: + method = getattr(quant_config, "method", None) + if method is None: + raise NotImplementedError( + "Diffusers backend quantization conversion requires a quantization " + "config with get_name(), quant_method, or method." + ) + return _normalize_method_name(method) + + +def _ensure_no_ignored_layers(quant_config: Any) -> None: + ignored_layers = getattr(quant_config, "ignored_layers", None) + if not ignored_layers: + ignored_layers = getattr(quant_config, "modules_to_not_convert", None) + if ignored_layers: + raise NotImplementedError( + "Diffusers backend quantization conversion does not map vLLM " + "ignored_layers/modules_to_not_convert names to Diffusers module " + "names. Use diffusers_load_kwargs for a native Diffusers config." + ) + + +def _get_torchao_quant_type_cls(class_name: str) -> type[Any]: + try: + import torchao.quantization as torchao_quantization + except ImportError as exc: + raise ImportError( + "Diffusers backend quantization conversion for fp8/int8 requires " + "torchao. Install torchao or pass a Diffusers-native " + "quantization_config through diffusers_load_kwargs." + ) from exc + + try: + return getattr(torchao_quantization, class_name) + except AttributeError as exc: + raise ImportError(f"torchao.quantization.{class_name} is required for this quantization mapping.") from exc + + +def _build_diffusers_native_quantization_config(config: dict[str, Any]) -> Any: + config = config.copy() + quant_mapping = config.get("quant_mapping") + if isinstance(quant_mapping, dict): + config["quant_mapping"] = { + component_name: _build_diffusers_component_quant_config(component_config) + for component_name, component_config in quant_mapping.items() + } + return PipelineQuantizationConfig(**config) + + +def _build_diffusers_component_quant_config(config: Any) -> Any: + if not isinstance(config, dict): + return config + + quant_method = config.get("quant_method") + if quant_method is None: + raise ValueError("Diffusers quant_mapping entries must provide quant_method.") + + quant_method = _normalize_method_name(quant_method) + config_cls = _get_diffusers_quantization_config_cls(quant_method) + from_dict = getattr(config_cls, "from_dict", None) + if from_dict is not None: + return from_dict(config) + + init_kwargs = config.copy() + init_kwargs.pop("quant_method", None) + return config_cls(**init_kwargs) + + +def _get_diffusers_quantization_config_cls(quant_method: str) -> type[Any]: + from diffusers.quantizers.auto import AUTO_QUANTIZATION_CONFIG_MAPPING as DIFFUSERS_QUANT_CONFIG_MAPPING + + config_cls = DIFFUSERS_QUANT_CONFIG_MAPPING.get(quant_method) + if config_cls is not None: + return config_cls + + try: + from transformers.quantizers.auto import ( + AUTO_QUANTIZATION_CONFIG_MAPPING as TRANSFORMERS_QUANT_CONFIG_MAPPING, + ) + except ImportError: + TRANSFORMERS_QUANT_CONFIG_MAPPING = {} + + config_cls = TRANSFORMERS_QUANT_CONFIG_MAPPING.get(quant_method) + if config_cls is None: + raise ValueError(f"Diffusers quant_mapping entry uses unknown quant_method={quant_method!r}.") + return config_cls + + +def _build_torchao_pipeline_quant_config( + torchao_quant_type_name: str, + transformer_components: list[str], +) -> Any: + """Build a Diffusers config after the caller has validated the vLLM config.""" + + quant_type_cls = _get_torchao_quant_type_cls(torchao_quant_type_name) + return PipelineQuantizationConfig( + quant_mapping={ + component_name: TorchAoConfig( + quant_type=quant_type_cls(), + ) + for component_name in transformer_components + } + ) + + +def _get_diffusers_transformer_components(component_names: dict[str, Any]) -> list[str]: + transformer_components = [ + component_name for component_name in component_names if component_name in _DIFFUSERS_TRANSFORMER_COMPONENTS + ] + if transformer_components: + return transformer_components + + available_components = ", ".join(sorted(component_names.keys())) or "" + raise NotImplementedError( + "Diffusers backend quantization conversion currently only supports " + "pipelines with transformer or transformer_2 components. " + f"Found pipeline components: {available_components}. Use " + "diffusers_load_kwargs for a native Diffusers quantization config." + ) + + +def _validate_fp8_quant_config(quant_config: Any) -> None: + if getattr(quant_config, "is_checkpoint_fp8_serialized", False): + raise NotImplementedError( + "Diffusers backend fp8 conversion only supports online/dynamic " + "TorchAO quantization; serialized vLLM fp8 checkpoints are not mapped." + ) + + activation_scheme = getattr(quant_config, "activation_scheme", "dynamic") + if activation_scheme != "dynamic": + raise NotImplementedError( + f"Diffusers backend fp8 conversion only supports activation_scheme='dynamic'. Got {activation_scheme!r}." + ) + + weight_block_size = getattr(quant_config, "weight_block_size", None) + if weight_block_size is not None: + raise NotImplementedError( + "Diffusers backend fp8 conversion does not map vLLM weight_block_size " + "to TorchAO. Use diffusers_load_kwargs for a native Diffusers config." + ) + + _ensure_no_ignored_layers(quant_config) + + +def _validate_int8_quant_config(quant_config: Any) -> None: + if getattr(quant_config, "is_checkpoint_int8_serialized", False): + raise NotImplementedError( + "Diffusers backend int8 conversion only supports online/dynamic " + "TorchAO quantization; serialized vLLM int8 checkpoints are not mapped." + ) + + activation_scheme = getattr(quant_config, "activation_scheme", "dynamic") + if activation_scheme != "dynamic": + raise NotImplementedError( + f"Diffusers backend int8 conversion only supports activation_scheme='dynamic'. Got {activation_scheme!r}." + ) + + _ensure_no_ignored_layers(quant_config) + + +_QUANTIZATION_CONFIG_SPECS: dict[str, _QuantizationConfigSpec] = { + "fp8": (_validate_fp8_quant_config, "Float8DynamicActivationFloat8WeightConfig"), + "int8": (_validate_int8_quant_config, "Int8DynamicActivationInt8WeightConfig"), +} + + +def _get_quantization_config_spec(method: str) -> _QuantizationConfigSpec: + spec = _QUANTIZATION_CONFIG_SPECS.get(method) + if spec is None: + raise NotImplementedError( + f"Diffusers backend quantization conversion does not support {method!r}. " + "Use diffusers_load_kwargs for a native Diffusers quantization config, " + "or use a native vLLM-Omni pipeline for this quantization method." + ) + return spec + + +def _build_diffusers_quantization_config( + quant_config: Any, + transformer_components: list[str], +) -> Any: + """Build a Diffusers PipelineQuantizationConfig from a supported config.""" + + method = _get_quant_method_name(quant_config) + _, torchao_quant_type_name = _get_quantization_config_spec(method) + return _build_torchao_pipeline_quant_config( + torchao_quant_type_name, + transformer_components, + ) From 3023bc40c2b78e1afef961ffb8e299aa44343f9b Mon Sep 17 00:00:00 2001 From: Mu GuanLin <1203789601@qq.com> Date: Fri, 3 Jul 2026 11:29:22 +0800 Subject: [PATCH 008/101] [Feature][Refactor] LTX-2.3 I2V Support, Public Examples, and Shared Pipeline Refactor (#4739) Signed-off-by: mglyn <1203789601@qq.com> --- .../offline_inference/image_to_video.md | 39 +- .../offline_inference/text_to_video.md | 30 +- .../image_to_video/README.md | 39 +- .../image_to_video/image_to_video.py | 48 +- .../text_to_video/text_to_video.md | 30 +- .../text_to_video/text_to_video.py | 8 +- recipes/LTX/LTX-2.3.md | 81 +- .../ltx2/test_ltx2_3_forward_parallel.py | 436 ++++++ .../ltx2/test_ltx2_3_image2video_pipeline.py | 244 +++ .../models/ltx2/test_ltx2_3_pipeline.py | 993 ++++-------- .../models/ltx2/test_ltx2_3_vae_tiling.py | 282 ++++ vllm_omni/diffusion/models/ltx2/__init__.py | 6 +- .../diffusion/models/ltx2/pipeline_ltx2_3.py | 1389 ++++++++++++----- .../ltx2/pipeline_ltx2_3_image2video.py | 448 +++++- 14 files changed, 2897 insertions(+), 1176 deletions(-) create mode 100644 tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py create mode 100644 tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py create mode 100644 tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py diff --git a/docs/user_guide/examples/offline_inference/image_to_video.md b/docs/user_guide/examples/offline_inference/image_to_video.md index 6e105741a..dc1d3d6b2 100644 --- a/docs/user_guide/examples/offline_inference/image_to_video.md +++ b/docs/user_guide/examples/offline_inference/image_to_video.md @@ -3,7 +3,9 @@ Source . -This example demonstrates how to generate videos from images using Wan2.2 Image-to-Video models with vLLM-Omni's offline inference API. +This example demonstrates how to generate videos from images using Wan2.2, +LTX-2/LTX-2.3, and HunyuanVideo-1.5 Image-to-Video models with vLLM-Omni's +offline inference API. ## Local CLI Usage @@ -51,13 +53,41 @@ python image_to_video.py \ --output i2v_output.mp4 ``` +### LTX-2.3 + +```bash +python image_to_video.py \ + --model dg845/LTX-2.3-Diffusers \ + --model-class-name LTX23ImageToVideoPipeline \ + --image cherry_blossom.jpg \ + --prompt "Cherry blossoms swaying gently in the breeze with synchronized ambient sound" \ + --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ + --height 384 \ + --width 512 \ + --num-frames 25 \ + --guidance-scale 4.0 \ + --num-inference-steps 20 \ + --frame-rate 24 \ + --fps 24 \ + --output ltx23_i2v_output.mp4 +``` + +Use a Diffusers-format checkpoint such as `dg845/LTX-2.3-Diffusers`; the +upstream `Lightricks/LTX-2.3` raw safetensors repo is not directly loadable by +this pipeline. Pass `--model-class-name LTX23ImageToVideoPipeline` to select +the LTX-2.3 image-to-video pipeline. + Key arguments: -- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V). +- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V, or + LTX-2/LTX-2.3). - `--image`: Path to input image (required). - `--prompt`: Text description of desired motion/animation. -- `--height/--width`: Output resolution (auto-calculated from image if not set). Dimensions should be multiples of 16. -- `--num-frames`: Number of frames (default 81). +- `--height/--width`: Output resolution (auto-calculated from image if not set). + Wan dimensions should be multiples of 16; LTX dimensions should be multiples + of 32. +- `--num-frames`: Number of frames (model-specific default; LTX-style models + work best with `8k + 1`). - `--guidance-scale` and `--guidance-scale-high`: CFG scale (applied to low/high-noise stages for MoE). - `--negative-prompt`: Optional list of artifacts to suppress. - `--boundary-ratio`: Boundary split ratio for two-stage MoE models. @@ -65,6 +95,7 @@ Key arguments: - `--sample-solver`: Wan2.2 sampling solver. Use `unipc` for the default multistep solver, or `euler` for Lightning/Distill checkpoints. - `--num-inference-steps`: Number of denoising steps (default 50). - `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video). +- `--audio-sample-rate`: fallback audio sample rate for embedded audio. - `--output`: Path to save the generated video. - `--vae-use-slicing`: Enable VAE slicing for memory optimization. - `--vae-use-tiling`: Enable VAE tiling for memory optimization. diff --git a/docs/user_guide/examples/offline_inference/text_to_video.md b/docs/user_guide/examples/offline_inference/text_to_video.md index a09dbfc97..5d89eac34 100644 --- a/docs/user_guide/examples/offline_inference/text_to_video.md +++ b/docs/user_guide/examples/offline_inference/text_to_video.md @@ -12,6 +12,7 @@ For backend selection and SageAttention usage, see the [Diffusion Attention Back | Model | Default Resolution | Default Frames | Default Steps | Guidance | VRAM (BF16) | |---|---|---|---|---|---| | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB | +| `dg845/LTX-2.3-Diffusers` | 384x512 | 25 | 20 | 4.0 | 96GB-class GPU | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required | @@ -50,6 +51,30 @@ python text_to_video.py \ --output ltx2_out.mp4 ``` +### LTX-2.3 + +```bash +python text_to_video.py \ + --model dg845/LTX-2.3-Diffusers \ + --model-class-name LTX23Pipeline \ + --prompt "Cherry blossoms swaying gently in the breeze with synchronized ambient sound" \ + --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ + --height 384 \ + --width 512 \ + --num-frames 25 \ + --num-inference-steps 20 \ + --guidance-scale 4.0 \ + --frame-rate 24 \ + --fps 24 \ + --audio-sample-rate 48000 \ + --output ltx23_t2v_output.mp4 +``` + +Use a Diffusers-format checkpoint such as `dg845/LTX-2.3-Diffusers`; the +upstream `Lightricks/LTX-2.3` raw safetensors repo is not directly loadable by +this pipeline. Pass `--model-class-name LTX23Pipeline` to select the LTX-2.3 +text-to-video pipeline explicitly. + ### HunyuanVideo-1.5 (480p) ```bash @@ -110,6 +135,8 @@ python text_to_video.py \ ### Common - `--model`: Diffusers model ID or local path. +- `--model-class-name`: Optional explicit pipeline class. Use `LTX23Pipeline` + for LTX-2.3 text-to-video. - `--prompt`: text description (string). - `--height/--width`: output resolution. Default depends on model. - `--num-frames`: number of frames. Default depends on model. @@ -124,7 +151,8 @@ python text_to_video.py \ - `--enable-cpu-offload`: enable CPU offloading for diffusion models. - `--enable-layerwise-offload`: enable layerwise offloading on DiT modules. - `--frame-rate`: generation FPS for pipelines that require it (e.g., LTX2). -- `--audio-sample-rate`: audio sample rate for embedded audio (when the pipeline returns audio). +- `--audio-sample-rate`: audio sample rate for embedded audio (when the + pipeline returns audio; LTX-2.3 outputs 48kHz audio). - `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF). - `--flow-shift`: scheduler flow_shift parameter. diff --git a/examples/offline_inference/image_to_video/README.md b/examples/offline_inference/image_to_video/README.md index 68bddca06..17977bb7a 100644 --- a/examples/offline_inference/image_to_video/README.md +++ b/examples/offline_inference/image_to_video/README.md @@ -1,7 +1,7 @@ # Image-To-Video -This shared example generates videos from images with VACE, Wan2.2, LTX2, -HunyuanVideo-1.5, Cosmos3, and other compatible pipelines. +This shared example generates videos from images with VACE, Wan2.2, +LTX-2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, and other compatible pipelines. ## Local CLI Usage @@ -49,6 +49,30 @@ python image_to_video.py \ --output i2v_output.mp4 ``` +### LTX-2.3 + +```bash +python image_to_video.py \ + --model dg845/LTX-2.3-Diffusers \ + --model-class-name LTX23ImageToVideoPipeline \ + --image cherry_blossom.jpg \ + --prompt "Cherry blossoms swaying gently in the breeze with synchronized ambient sound" \ + --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ + --height 384 \ + --width 512 \ + --num-frames 25 \ + --guidance-scale 4.0 \ + --num-inference-steps 20 \ + --frame-rate 24 \ + --fps 24 \ + --output ltx23_i2v_output.mp4 +``` + +Use a Diffusers-format checkpoint such as `dg845/LTX-2.3-Diffusers`; the +upstream `Lightricks/LTX-2.3` raw safetensors repo is not directly loadable by +this pipeline. Pass `--model-class-name LTX23ImageToVideoPipeline` to select +the LTX-2.3 image-to-video pipeline. + ### Cosmos3 ```bash @@ -66,15 +90,19 @@ python image_to_video.py \ Key arguments: -- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V). +- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V, + LTX-2/LTX-2.3, Cosmos3, or VACE). - `--image`: Path to the first-frame or source image. - `--last-image`: Optional last-frame condition for models such as VACE. - `--mask-image`: Optional inpainting mask. White pixels are regenerated and black pixels are preserved. - `--reference-image`: Optional reference image; repeat it to provide multiple references. - `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3. - `--prompt`: Text description of desired motion/animation. -- `--height/--width`: Output resolution (auto-calculated from image if not set). Dimensions should be multiples of 16. -- `--num-frames`: Number of frames (default: model-specific — Wan 81, LTX2 121, Cosmos3 189). +- `--height/--width`: Output resolution (auto-calculated from image if not set). + Wan dimensions should be multiples of 16; LTX dimensions should be multiples + of 32. +- `--num-frames`: Number of frames (model-specific default; LTX-style models + work best with `8k + 1`; Cosmos3 defaults to 189). - `--guidance-scale` and `--guidance-scale-high`: CFG scale (applied to low/high-noise stages for MoE). - `--negative-prompt`: Optional list of artifacts to suppress. - `--boundary-ratio`: Boundary split ratio for two-stage MoE models. @@ -82,6 +110,7 @@ Key arguments: - `--sample-solver`: Wan2.2 sampling solver. Use `unipc` for the default multistep solver, or `euler` for Lightning/Distill checkpoints. - `--num-inference-steps`: Number of denoising steps (default: model-specific — Wan 50, LTX2 40, Cosmos3 35). - `--fps`: Frames per second for the saved MP4 (requires `diffusers` export_to_video). +- `--audio-sample-rate`: fallback audio sample rate for embedded audio. - `--output`: Path to save the generated video. - `--vae-use-slicing`: Enable VAE slicing for memory optimization. - `--vae-use-tiling`: Enable VAE tiling for memory optimization. diff --git a/examples/offline_inference/image_to_video/image_to_video.py b/examples/offline_inference/image_to_video/image_to_video.py index cc8f1ff2a..1a0691ebb 100644 --- a/examples/offline_inference/image_to_video/image_to_video.py +++ b/examples/offline_inference/image_to_video/image_to_video.py @@ -2,7 +2,8 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """ -Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE. +Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2/LTX-2.3, +HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE. Supports: - Wan2.2-I2V-A14B-Diffusers: MoE model with CLIP image encoder @@ -27,6 +28,13 @@ Usage: --num-frames 121 --num-inference-steps 40 --guidance-scale 4.0 \ --frame-rate 24 --fps 24 --output ltx2_i2v.mp4 + # LTX-2.3 image-to-video + python image_to_video.py --model dg845/LTX-2.3-Diffusers \ + --model-class-name LTX23ImageToVideoPipeline \ + --image input.jpg --prompt "A cinematic dolly shot of a boat" \ + --height 384 --width 512 --num-frames 25 --num-inference-steps 20 \ + --frame-rate 24 --fps 24 --output ltx23_i2v.mp4 + # HunyuanVideo-1.5 I2V (480p) python image_to_video.py --model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v \ --image input.jpg --prompt "A cat playing with yarn" \ @@ -79,17 +87,20 @@ parse_profiler_config = functools.partial(parse_json_object, flag_name="--profil def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Generate a video from one or more images (Wan2.2, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE)." + description=( + "Generate a video from one or more images " + "(Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE)." + ) ) parser.add_argument( "--model", default="Wan-AI/Wan2.2-I2V-A14B-Diffusers", - help="Diffusers I2V model ID or local path (Wan2.2, HunyuanVideo-1.5, or Wan2.1 VACE).", + help="Diffusers I2V model ID or local path (Wan2.2, LTX2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, or Wan2.1 VACE).", ) parser.add_argument( "--model-class-name", default=None, - help="Override model class name (e.g., LTX2ImageToVideoPipeline).", + help="Override model class name (e.g., LTX2ImageToVideoPipeline or LTX23ImageToVideoPipeline).", ) parser.add_argument("--image", help="Path to the first-frame or source image.") parser.add_argument("--last-image", help="Path to a last-frame condition (used by models such as VACE).") @@ -113,7 +124,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--width", type=int, default=None, help="Video width (auto-calculated from image if not set).") parser.add_argument("--num-frames", type=int, default=None, help="Number of frames. Default: model-specific.") parser.add_argument( - "--num-inference-steps", type=int, default=None, help="Sampling steps. Default: model-specific." + "--num-inference-steps", + type=int, + default=None, + help="Sampling steps. Default: model-specific.", ) parser.add_argument("--boundary-ratio", type=float, default=0.875, help="Boundary split ratio for MoE models.") parser.add_argument( @@ -312,10 +326,8 @@ def main(): generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed) model_name = str(args.model).lower() if args.model is not None else "" model_class_name = args.model_class_name - is_ltx2 = "ltx2" in model_name or (model_class_name and "ltx2" in model_class_name.lower()) + is_ltx2 = model_class_name in {"LTX2ImageToVideoPipeline", "LTX23ImageToVideoPipeline"} is_cosmos = "cosmos" in model_name or (model_class_name is not None and "cosmos" in model_class_name.lower()) - if model_class_name is None and is_ltx2: - model_class_name = "LTX2ImageToVideoPipeline" image = PIL.Image.open(args.image).convert("RGB") if args.image else None last_image = PIL.Image.open(args.last_image).convert("RGB") if args.last_image else None @@ -340,7 +352,15 @@ def main(): 16, ) elif is_ltx2: - d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = 24, 4.0, 121, 40, 5.0, 512 * 768, 32 + d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = ( + 24, + 4.0, + 121, + 40, + 5.0, + 512 * 768, + 32, + ) else: # Wan2.2 / HunyuanVideo-1.5 d_fps, d_guidance, d_num_frames, d_steps, d_flow_shift, d_max_area, d_mod = 16, 5.0, 81, 50, 5.0, 480 * 832, 16 @@ -462,7 +482,7 @@ def main(): f" tensor_parallel_size={args.tensor_parallel_size}, vae_patch_parallel_size={args.vae_patch_parallel_size}," f" pipeline_parallel_size={args.pipeline_parallel_size}" ) - print(f" Video size: {args.width}x{args.height}") + print(f" Video size: {width}x{height}") print(f"{'=' * 60}\n") prompt_dict = build_image_to_video_prompt( @@ -509,6 +529,7 @@ def main(): print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)") audio = None + audio_sample_rate = args.audio_sample_rate if isinstance(frames, list): frames = frames[0] if frames else None @@ -519,11 +540,13 @@ def main(): ) if frames.multimodal_output and "audio" in frames.multimodal_output: audio = frames.multimodal_output["audio"] + audio_sample_rate = frames.multimodal_output.get("audio_sample_rate", audio_sample_rate) if frames.is_pipeline_output and frames.request_output is not None: inner_output = frames.request_output if isinstance(inner_output, OmniRequestOutput): if inner_output.multimodal_output and "audio" in inner_output.multimodal_output: audio = inner_output.multimodal_output["audio"] + audio_sample_rate = inner_output.multimodal_output.get("audio_sample_rate", audio_sample_rate) frames = inner_output if isinstance(frames, OmniRequestOutput): if frames.images: @@ -531,6 +554,7 @@ def main(): frames, audio = frames.images[0] elif len(frames.images) == 1 and isinstance(frames.images[0], dict): audio = frames.images[0].get("audio") + audio_sample_rate = frames.images[0].get("audio_sample_rate", audio_sample_rate) frames = frames.images[0].get("frames") or frames.images[0].get("video") else: frames = frames.images @@ -543,6 +567,7 @@ def main(): frames, audio = first_item elif isinstance(first_item, dict): audio = first_item.get("audio") + audio_sample_rate = first_item.get("audio_sample_rate", audio_sample_rate) frames = first_item.get("frames") or first_item.get("video") elif isinstance(first_item, list): frames = first_item @@ -551,6 +576,7 @@ def main(): frames, audio = frames elif isinstance(frames, dict): audio = frames.get("audio") + audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate) frames = frames.get("frames") or frames.get("video") if frames is None: @@ -669,7 +695,7 @@ def main(): frames_u8, audio_np, fps=float(fps), - audio_sample_rate=args.audio_sample_rate, + audio_sample_rate=audio_sample_rate, ) with open(str(output_path), "wb") as f: f.write(video_bytes) diff --git a/examples/offline_inference/text_to_video/text_to_video.md b/examples/offline_inference/text_to_video/text_to_video.md index a2634d5ae..4c2b5a83a 100644 --- a/examples/offline_inference/text_to_video/text_to_video.md +++ b/examples/offline_inference/text_to_video/text_to_video.md @@ -8,6 +8,7 @@ A unified script for text-to-video generation. Supports multiple models with mod |---|---|---|---|---|---| | `Wan-AI/Wan2.1-VACE-1.3B-diffusers` | 480x832 | 81 | 30 | 5.0 | ~20 GiB (RTX 5090, VAE tiling) | | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB | +| `dg845/LTX-2.3-Diffusers` | 384x512 | 25 | 20 | 4.0 | 96GB-class GPU | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB | | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required | | `nvidia/Cosmos3-Nano` | 720x1280 | 189 | 35 | 6.0 | ~46 GiB (peak, 720p) | @@ -70,6 +71,30 @@ python text_to_video.py \ --output ltx2_out.mp4 ``` +### LTX-2.3 + +```bash +python text_to_video.py \ + --model dg845/LTX-2.3-Diffusers \ + --model-class-name LTX23Pipeline \ + --prompt "Cherry blossoms swaying gently in the breeze with synchronized ambient sound" \ + --negative-prompt "worst quality, inconsistent motion, blurry, jittery, distorted" \ + --height 384 \ + --width 512 \ + --num-frames 25 \ + --num-inference-steps 20 \ + --guidance-scale 4.0 \ + --frame-rate 24 \ + --fps 24 \ + --audio-sample-rate 48000 \ + --output ltx23_t2v_output.mp4 +``` + +Use a Diffusers-format checkpoint such as `dg845/LTX-2.3-Diffusers`; the +upstream `Lightricks/LTX-2.3` raw safetensors repo is not directly loadable by +this pipeline. Pass `--model-class-name LTX23Pipeline` to select the LTX-2.3 +text-to-video pipeline explicitly. + ### HunyuanVideo-1.5 (480p) ```bash @@ -187,6 +212,8 @@ python text_to_video.py \ ### Common - `--model`: Diffusers model ID or local path. +- `--model-class-name`: Optional explicit pipeline class. Use `LTX23Pipeline` + for LTX-2.3 text-to-video. - `--prompt`: text description (string). - `--height/--width`: output resolution. Default depends on model. - `--num-frames`: number of frames. Default depends on model. @@ -202,7 +229,8 @@ python text_to_video.py \ - `--enable-cpu-offload`: enable CPU offloading for diffusion models. - `--enable-layerwise-offload`: enable layerwise offloading on DiT modules. - `--frame-rate`: generation FPS for pipelines that require it (e.g., LTX2). -- `--audio-sample-rate`: audio sample rate for embedded audio (when the pipeline returns audio). +- `--audio-sample-rate`: audio sample rate for embedded audio (when the + pipeline returns audio; LTX-2.3 outputs 48kHz audio). - `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF). - `--flow-shift`: scheduler flow_shift parameter. - `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3 (see above). diff --git a/examples/offline_inference/text_to_video/text_to_video.py b/examples/offline_inference/text_to_video/text_to_video.py index 1323985e5..200515a83 100644 --- a/examples/offline_inference/text_to_video/text_to_video.py +++ b/examples/offline_inference/text_to_video/text_to_video.py @@ -455,6 +455,7 @@ def main(): print(f"Worker peak GPU memory (reserved): {peak_mb:.2f} MiB ({peak_mb / 1024:.2f} GiB)") audio = None + audio_sample_rate = args.audio_sample_rate if isinstance(frames, list): frames = frames[0] if frames else None @@ -465,11 +466,13 @@ def main(): ) if frames.multimodal_output and "audio" in frames.multimodal_output: audio = frames.multimodal_output["audio"] + audio_sample_rate = frames.multimodal_output.get("audio_sample_rate", audio_sample_rate) if frames.is_pipeline_output and frames.request_output is not None: inner_output = frames.request_output if isinstance(inner_output, OmniRequestOutput): if inner_output.multimodal_output and "audio" in inner_output.multimodal_output: audio = inner_output.multimodal_output["audio"] + audio_sample_rate = inner_output.multimodal_output.get("audio_sample_rate", audio_sample_rate) frames = inner_output if isinstance(frames, OmniRequestOutput): if frames.images: @@ -477,6 +480,7 @@ def main(): frames, audio = frames.images[0] elif len(frames.images) == 1 and isinstance(frames.images[0], dict): audio = frames.images[0].get("audio") + audio_sample_rate = frames.images[0].get("audio_sample_rate", audio_sample_rate) frames = frames.images[0].get("frames") or frames.images[0].get("video") else: frames = frames.images @@ -489,6 +493,7 @@ def main(): frames, audio = first_item elif isinstance(first_item, dict): audio = first_item.get("audio") + audio_sample_rate = first_item.get("audio_sample_rate", audio_sample_rate) frames = first_item.get("frames") or first_item.get("video") elif isinstance(first_item, list): frames = first_item @@ -497,6 +502,7 @@ def main(): frames, audio = frames elif isinstance(frames, dict): audio = frames.get("audio") + audio_sample_rate = frames.get("audio_sample_rate", audio_sample_rate) frames = frames.get("frames") or frames.get("video") if frames is None: @@ -611,7 +617,7 @@ def main(): frames_u8, audio_np, fps=float(args.fps), - audio_sample_rate=args.audio_sample_rate, + audio_sample_rate=audio_sample_rate, ) with open(str(output_path), "wb") as f: f.write(video_bytes) diff --git a/recipes/LTX/LTX-2.3.md b/recipes/LTX/LTX-2.3.md index 269ea6425..c83a14883 100644 --- a/recipes/LTX/LTX-2.3.md +++ b/recipes/LTX/LTX-2.3.md @@ -6,21 +6,23 @@ - Vendor: Lightricks - Model: `dg845/LTX-2.3-Diffusers` -- Task: Text-to-video with synchronized audio generation +- Task: Text-to-video and image-to-video with synchronized audio generation - Mode: Online serving (pure diffusion) - Maintainer: @oglok ## When to use this recipe -Use this recipe when you want to serve LTX-2.3 for text-to-video generation -with audio. The model generates videos up to 20+ seconds at 768x512 resolution -with 48kHz audio, all from a single text prompt. Requires a GPU with at least -96GB VRAM due to the 22B parameter transformer (~44GB weights) plus text +Use this recipe when you want to serve LTX-2.3 for text-to-video or +image-to-video generation with audio. The model generates videos up to 20+ +seconds at 768x512 resolution with 48kHz audio from text prompts, and can +condition video generation on an initial image. Start with a 96GB-class GPU for +validation because the model combines a 22B parameter transformer with text encoder, VAE, and vocoder components. ## References -- Model: +- Upstream raw checkpoints: +- Diffusers-format checkpoint: - Requires `diffusers >= 0.38.0` (install from git: `pip install git+https://github.com/huggingface/diffusers.git`) ## Serving @@ -34,7 +36,16 @@ vllm serve dg845/LTX-2.3-Diffusers \ --stage-init-timeout 600 ``` -### Verification +For image-to-video: + +```bash +vllm serve dg845/LTX-2.3-Diffusers \ + --omni \ + --model-class-name LTX23ImageToVideoPipeline \ + --stage-init-timeout 600 +``` + +### T2V Verification ```bash # Health check @@ -73,19 +84,48 @@ curl -X POST http://localhost:8000/v1/videos \ -F "guidance_scale=4.0" ``` +### I2V Verification + +Start the server with `--model-class-name LTX23ImageToVideoPipeline`, then pass +an image reference. The I2V pipeline requires `input_reference` or +`image_reference` because the first frame is the conditioning input. + +```bash +curl http://localhost:8000/health + +curl -X POST http://localhost:8000/v1/videos/sync \ + -F "prompt=A plush toy astronaut gently waving while the camera slowly pushes in." \ + -F "input_reference=@/absolute/path/to/reference.png" \ + -F "model=dg845/LTX-2.3-Diffusers" \ + -F "num_frames=81" \ + -F "fps=24" \ + -F "size=768x512" \ + -F "num_inference_steps=30" \ + -F "guidance_scale=4.0" \ + -F "seed=42" \ + -o ltx23_i2v.mp4 +``` + ### Notes -- Memory usage: Model loads at ~36 GiB, peaks at ~62 GiB during inference +- Memory usage: run a latest-head serving smoke or DFX benchmark on the target + GPU before publishing load/peak VRAM numbers. +- Checkpoint format: use a Diffusers-format checkpoint such as + `dg845/LTX-2.3-Diffusers`; the upstream `Lightricks/LTX-2.3` repository ships + raw safetensors and does not contain the subfolder configs required by this + pipeline loader. - Key flags: - - `--stage-init-timeout 600`: Required for the initial `torch.compile` warmup (~90-140 seconds on first request) - - `--model-class-name LTX23Pipeline`: Selects the LTX-2.3 pipeline (not LTX-2) + - `--stage-init-timeout 600`: Gives the initial `torch.compile` warmup enough time on large checkpoints + - `--model-class-name LTX23Pipeline`: Selects the LTX-2.3 text-to-video pipeline (not LTX-2) + - `--model-class-name LTX23ImageToVideoPipeline`: Selects the LTX-2.3 image-to-video pipeline + - `input_reference=@...`: Uploads the I2V reference image directly - Audio: 48kHz AAC via BWE vocoder, automatically synced with video - CPU offloading: Text encoder (Gemma-3-12B), connectors, VAE, audio VAE, and vocoder stay on CPU and are moved to GPU only when needed - Supported resolutions: 768x512, 512x384 (must be divisible by 32) - Frame rate: 24 fps - Duration: Controlled by `num_frames` (frames = duration_seconds * 24 + 1) - Known limitations: - - No image-to-video support yet (LTX23ImageToVideoPipeline is a placeholder) + - LTX-2.3 I2V currently supports first-frame image conditioning, matching the LTX image-conditioning contract. - Requires `diffusers >= 0.38.0` (not yet on PyPI, install from git) ## Hardware Support @@ -99,13 +139,16 @@ curl -X POST http://localhost:8000/v1/videos \ - OS: Ubuntu 22.04 - Python: 3.10+ - Driver / runtime: CUDA 13.0, Driver 580.126.09 -- vLLM version: 0.19.x -- vLLM-Omni version: 0.19.x +- vLLM version: Match the PR or release checkout being validated +- vLLM-Omni version or commit: Use the commit you are deploying from -### Validated configurations +### Benchmarking -| Duration | Frames | Resolution | Steps | Guidance | Inference Time | Peak VRAM | -|----------|--------|------------|-------|----------|----------------|-----------| -| 3s | 81 | 768x512 | 30 | 4.0 | ~110s | ~62 GB | -| 10s | 241 | 768x512 | 30 | 4.0 | ~130s | ~62 GB | -| 20s | 481 | 768x512 | 30 | 4.0 | ~420s | ~62 GB | +Do not copy latency or VRAM numbers between commits or machines. Record them +only after running a latest-head sweep on the target hardware. + +For formal PR benchmarking, reuse +`tests/dfx/perf/tests/test_ltx2_3_vllm_omni.json` with +`tests/dfx/perf/scripts/run_diffusion_benchmark.py`. That config captures the +single-device eager baseline and CFG-parallel=2 case for a small 384x512, +25-frame, 20-step workload. diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py b/tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py new file mode 100644 index 000000000..3e592f14b --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_3_forward_parallel.py @@ -0,0 +1,436 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for LTX-2.3 forward-parallel behavior.""" + +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestCFGParallelIntegration: + def test_ltx23_pipeline_has_cfg_parallel_mixin(self): + """LTX23Pipeline must use the shared CFG parallel implementation.""" + from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + + assert issubclass(LTX23Pipeline, CFGParallelMixin) + + def test_ltx2_transformer_has_dit_cache_config(self): + """Ensure LTX2 has a Cache DiT adapter config and that it uses separate CFG.""" + from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig + from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel + + adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config") + assert isinstance(adapter_config, CacheDiTAdapterConfig) + assert adapter_config.has_separate_cfg + + +class TestCFGParallelHelpers: + """Test LTX-2.3 CFG helper math without loading model weights.""" + + def test_combine_cfg_noise_matches_x0_space_formula(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + + pipe = object.__new__(LTX23Pipeline) + video_sample = torch.tensor([[[1.0, -2.0]]]) + audio_sample = torch.tensor([[[0.5, 3.0]]]) + video_pos = torch.tensor([[[0.2, -0.3]]]) + video_neg = torch.tensor([[[-0.4, 0.1]]]) + audio_pos = torch.tensor([[[0.7, -0.2]]]) + audio_neg = torch.tensor([[[0.1, 0.4]]]) + video_sigma = torch.tensor(0.25) + audio_sigma = torch.tensor(0.5) + scale = 4.0 + + video_combined, audio_combined = pipe.combine_cfg_noise( + (video_pos, audio_pos), + (video_neg, audio_neg), + scale, + video_latents=video_sample, + audio_latents=audio_sample, + video_sigma=video_sigma, + audio_sigma=audio_sigma, + ) + + x0_video_cond = video_sample - video_pos * video_sigma + x0_video_uncond = video_sample - video_neg * video_sigma + x0_video_guided = x0_video_cond + (scale - 1) * (x0_video_cond - x0_video_uncond) + expected_video = (video_sample - x0_video_guided) / video_sigma + + x0_audio_cond = audio_sample - audio_pos * audio_sigma + x0_audio_uncond = audio_sample - audio_neg * audio_sigma + x0_audio_guided = x0_audio_cond + (scale - 1) * (x0_audio_cond - x0_audio_uncond) + expected_audio = (audio_sample - x0_audio_guided) / audio_sigma + assert torch.allclose(video_combined, expected_video) + assert torch.allclose(audio_combined, expected_audio) + + def test_two_rank_cfg_parallel_smoke_uses_rank_local_branch_and_x0_formula(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + + pipe = object.__new__(ltx23.LTX23Pipeline) + video_sample = torch.tensor([[[1.0, -2.0]]]) + audio_sample = torch.tensor([[[0.5, 3.0, -1.0]]]) + video_pos = torch.tensor([[[0.2, -0.3]]]) + video_neg = torch.tensor([[[-0.4, 0.1]]]) + audio_pos = torch.tensor([[[0.7, -0.2, 0.3]]]) + audio_neg = torch.tensor([[[0.1, 0.4, -0.5]]]) + video_sigma = torch.tensor(0.25) + audio_sigma = torch.tensor(0.5) + scale = 4.0 + + class FakeCfgGroup: + def all_gather(self, tensor, separate_tensors=True): + assert separate_tensors + if tensor.shape == video_pos.shape: + return [video_pos, video_neg] + return [audio_pos, audio_neg] + + monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) + monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) + + expected_video = ltx23.LTX23Pipeline._combine_x0_space_cfg( + video_sample, + video_pos, + video_neg, + video_sigma, + scale, + ) + expected_audio = ltx23.LTX23Pipeline._combine_x0_space_cfg( + audio_sample, + audio_pos, + audio_neg, + audio_sigma, + scale, + ) + + for rank, expected_branch in ((0, "positive"), (1, "negative")): + calls = [] + monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda rank=rank: rank) + + def fake_predict_noise(**kwargs): + calls.append(kwargs["branch"]) + if kwargs["branch"] == "positive": + return video_pos, audio_pos + return video_neg, audio_neg + + object.__setattr__(pipe, "predict_noise", fake_predict_noise) + video_combined, audio_combined = pipe.predict_noise_with_parallel_cfg( + true_cfg_scale=scale, + positive_kwargs={"branch": "positive"}, + negative_kwargs={"branch": "negative"}, + cfg_normalize=False, + video_latents=video_sample, + audio_latents=audio_sample, + video_sigma=video_sigma, + audio_sigma=audio_sigma, + ) + + assert calls == [expected_branch] + torch.testing.assert_close(video_combined, expected_video) + torch.testing.assert_close(audio_combined, expected_audio) + + assert "_cfg_video_latents" not in pipe.__dict__ + assert "_cfg_audio_latents" not in pipe.__dict__ + + +class TestCFGParallelForwardPath: + """Test the LTX-2.3 CFG-parallel denoising path without loading model weights.""" + + def test_forward_collates_request_prompt_embeds_and_mask_aliases(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = object.__new__(ltx23.LTX23Pipeline) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 4 + monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 1) + + class StopAtEncodePromptError(Exception): + pass + + captured = {} + + def fake_encode_prompt(**kwargs): + captured.update(kwargs) + raise StopAtEncodePromptError + + object.__setattr__(pipe, "encode_prompt", fake_encode_prompt) + + prompt_embeds_a = torch.zeros(2, 3) + prompt_embeds_b = torch.ones(2, 3) + negative_prompt_embeds_a = torch.full((2, 3), 2.0) + negative_prompt_embeds_b = torch.full((2, 3), 3.0) + prompt_attention_mask_a = torch.tensor([True, True]) + prompt_attention_mask_b = torch.tensor([True, False]) + negative_attention_mask_a = torch.tensor([False, True]) + negative_attention_mask_b = torch.tensor([False, False]) + + requests = [ + OmniDiffusionRequest( + prompt={ + "prompt": "prompt-a", + "negative_prompt": "negative-a", + "prompt_embeds": prompt_embeds_a, + "negative_prompt_embeds": negative_prompt_embeds_a, + "prompt_attention_mask": prompt_attention_mask_a, + "negative_prompt_attention_mask": negative_attention_mask_a, + }, + sampling_params=OmniDiffusionSamplingParams( + height=32, + width=32, + num_frames=1, + frame_rate=1.0, + num_inference_steps=2, + ), + request_id="ltx23-prompt-local-a", + ), + OmniDiffusionRequest( + prompt={ + "prompt": "prompt-b", + "negative_prompt": "negative-b", + "prompt_embeds": prompt_embeds_b, + "negative_prompt_embeds": negative_prompt_embeds_b, + "attention_mask": prompt_attention_mask_b, + "negative_attention_mask": negative_attention_mask_b, + }, + sampling_params=OmniDiffusionSamplingParams( + height=32, + width=32, + num_frames=1, + frame_rate=1.0, + num_inference_steps=2, + ), + request_id="ltx23-prompt-local-b", + ), + ] + + with pytest.raises(StopAtEncodePromptError): + pipe.forward(DiffusionRequestBatch(requests=requests)) + + assert captured["prompt"] is None + assert captured["negative_prompt"] is None + torch.testing.assert_close( + captured["prompt_embeds"], + torch.stack([prompt_embeds_a, prompt_embeds_b], dim=0), + ) + torch.testing.assert_close( + captured["negative_prompt_embeds"], + torch.stack([negative_prompt_embeds_a, negative_prompt_embeds_b], dim=0), + ) + torch.testing.assert_close( + captured["prompt_attention_mask"], + torch.stack([prompt_attention_mask_a, prompt_attention_mask_b], dim=0), + ) + torch.testing.assert_close( + captured["negative_prompt_attention_mask"], + torch.stack([negative_attention_mask_a, negative_attention_mask_b], dim=0), + ) + + @pytest.mark.parametrize(("cfg_rank", "expected_prompt_value"), [(0, 1.0), (1, 0.0)]) + @pytest.mark.parametrize( + ("frame_rate_input", "audio_sampling_rate", "expected_frame_rate"), + [(1.0, 1, 1.0), (None, 24, 24.0)], + ) + def test_forward_cfg_parallel_steps_video_and_audio_scheduler( + self, + monkeypatch, + cfg_rank, + expected_prompt_value, + frame_rate_input, + audio_sampling_rate, + expected_frame_rate, + ): + from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = object.__new__(ltx23.LTX23Pipeline) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 1 + pipe.vae_spatial_compression_ratio = 32 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + pipe.audio_sampling_rate = audio_sampling_rate + pipe.audio_hop_length = 1 + pipe.audio_vae_temporal_compression_ratio = 1 + pipe.audio_vae_mel_compression_ratio = 1 + pipe.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=1)) + pipe.tokenizer = SimpleNamespace(padding_side="left") + pipe.vae = SimpleNamespace( + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + config=SimpleNamespace(scaling_factor=1.0), + ) + pipe.audio_vae = SimpleNamespace( + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + config=SimpleNamespace(mel_bins=2, latent_channels=1), + ) + + video_pos = torch.tensor([[[0.2, -0.3]]]) + video_neg = torch.tensor([[[-0.4, 0.1]]]) + audio_pos = torch.tensor([[[0.7, -0.2]]]) + audio_neg = torch.tensor([[[0.1, 0.4]]]) + + class FakeCfgGroup: + def all_gather(self, tensor, separate_tensors=True): + assert separate_tensors + if torch.equal(tensor, video_pos) or torch.equal(tensor, video_neg): + return [video_pos, video_neg] + if torch.equal(tensor, audio_pos) or torch.equal(tensor, audio_neg): + return [audio_pos, audio_neg] + raise AssertionError(f"Unexpected gathered tensor: {tensor}") + + monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) + monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda: cfg_rank) + monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) + + def fake_retrieve_timesteps(scheduler, num_inference_steps, device, timesteps, sigmas=None, mu=None): + scheduler.sigmas = torch.tensor([0.25, 0.25], device=device) + return torch.tensor([1.0, 0.5], device=device), 2 + + monkeypatch.setattr(ltx23, "retrieve_timesteps", fake_retrieve_timesteps) + + class FakeScheduler: + def __init__(self, name="video", calls=None): + self.name = name + self.calls = [] if calls is None else calls + self.config = { + "max_image_seq_len": 4096, + "base_image_seq_len": 1024, + "base_shift": 0.95, + "max_shift": 2.05, + } + self.sigmas = torch.tensor([0.25, 0.25]) + + def __deepcopy__(self, memo): + return FakeScheduler("audio", self.calls) + + def step(self, noise_pred, t, latents, return_dict=False, generator=None): + self.calls.append((self.name, noise_pred.clone(), t.clone(), latents.clone())) + return (latents - noise_pred,) + + class FakeConnectors: + def to(self, device): + return self + + def __call__(self, prompt_embeds, prompt_attention_mask, padding_side): + assert padding_side == "left" + assert prompt_embeds.shape[0] == 2 + return prompt_embeds, prompt_embeds, prompt_attention_mask + + rope_video_fps: list[float] = [] + + class FakeRope: + def prepare_video_coords(self, batch_size, num_frames, height, width, device, fps): + rope_video_fps.append(fps) + return torch.zeros(batch_size, num_frames * height * width, 3, device=device) + + def prepare_audio_coords(self, batch_size, num_frames, device): + return torch.zeros(batch_size, num_frames, 1, device=device) + + class FakeTransformer: + def __init__(self): + self.config = SimpleNamespace(in_channels=2) + self.rope = FakeRope() + self.audio_rope = FakeRope() + self.calls = [] + + def __call__(self, **kwargs): + self.calls.append(kwargs) + expected_prompt = torch.full((1, 1, 1), expected_prompt_value) + torch.testing.assert_close(kwargs["encoder_hidden_states"], expected_prompt) + torch.testing.assert_close(kwargs["audio_encoder_hidden_states"], expected_prompt) + assert kwargs["hidden_states"].shape == (1, 1, 2) + assert kwargs["audio_hidden_states"].shape == (1, 1, 2) + if cfg_rank == 0: + return video_pos, audio_pos + return video_neg, audio_neg + + class DummyProgress: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def update(self): + pass + + pipe.scheduler = FakeScheduler() + pipe.connectors = FakeConnectors() + pipe.transformer = FakeTransformer() + object.__setattr__(pipe, "progress_bar", lambda total: DummyProgress()) + + def fake_encode_prompt(**kwargs): + return ( + torch.ones(1, 1, 1), + torch.ones(1, 1, dtype=torch.bool), + torch.zeros(1, 1, 1), + torch.ones(1, 1, dtype=torch.bool), + ) + + object.__setattr__(pipe, "encode_prompt", fake_encode_prompt) + + video_latents = torch.tensor([[[1.0, -2.0]]]) + audio_latents = torch.tensor([[[0.5, 3.0]]]) + req = OmniDiffusionRequest( + prompt={"prompt": "prompt", "negative_prompt": "negative"}, + sampling_params=OmniDiffusionSamplingParams( + height=32, + width=32, + num_frames=1, + frame_rate=frame_rate_input, + num_inference_steps=2, + guidance_scale=4.0, + latents=video_latents, + audio_latents=audio_latents, + output_type="latent", + ), + request_id="ltx23-cfg-parallel-forward-test", + ) + + output = pipe.forward(DiffusionRequestBatch(requests=[req]))[0] + + expected_video_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( + video_latents, + video_pos, + video_neg, + pipe.scheduler.sigmas[0], + 4.0, + ) + expected_audio_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( + audio_latents, + audio_pos, + audio_neg, + pipe.scheduler.sigmas[0], + 4.0, + ) + scheduler_call_names = [call[0] for call in pipe.scheduler.calls] + assert scheduler_call_names == ["video", "audio", "video", "audio"] + assert len(pipe.transformer.calls) == 2 + torch.testing.assert_close(pipe.scheduler.calls[0][1], expected_video_noise) + torch.testing.assert_close(pipe.scheduler.calls[1][1], expected_audio_noise) + torch.testing.assert_close(pipe.scheduler.calls[2][1], expected_video_noise) + torch.testing.assert_close(pipe.scheduler.calls[3][1], expected_audio_noise) + torch.testing.assert_close(pipe.scheduler.calls[2][3], video_latents - expected_video_noise) + torch.testing.assert_close(pipe.scheduler.calls[3][3], audio_latents - expected_audio_noise) + + video_out, audio_out = output.output + torch.testing.assert_close(video_out, (video_latents - 2 * expected_video_noise).reshape(1, 2, 1, 1, 1)) + torch.testing.assert_close(audio_out, (audio_latents - 2 * expected_audio_noise).reshape(1, 1, 1, 2)) + + # fps regression guard: an omitted request fps (frame_rate_input=None) must resolve + # to the model's own 24.0 default, not crash on None; a provided rate is passed through. + assert rope_video_fps + assert all(fps == expected_frame_rate for fps in rope_video_fps) diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py new file mode 100644 index 000000000..9b883ff34 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_3_image2video_pipeline.py @@ -0,0 +1,244 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for the LTX-2.3 image-to-video pipeline.""" + +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_ltx23_request_pipe(cls): + pipe = object.__new__(cls) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 99 + return pipe + + +class TestLTX23ImageToVideoForwardStages: + def test_forward_resolves_request_image_and_delegates_to_shared_forward_impl(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx23_request_pipe(LTX23ImageToVideoPipeline) + image = torch.zeros(3, 8, 8) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "make the image move", + "negative_prompt": "jitter", + "multi_modal_data": {"image": image}, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-i2v-forward-stage-delegation", + ) + ] + ) + seen = {} + + def fake_forward_impl(req_arg, request_inputs, **kwargs): + seen["req"] = req_arg + seen["request_inputs"] = request_inputs + seen["kwargs"] = kwargs + return ["i2v-delegated"] + + object.__setattr__(pipe, "_forward_impl", fake_forward_impl) + + output = pipe.forward(req, noise_scale=0.5) + + assert output == ["i2v-delegated"] + assert seen["req"] is req + assert seen["request_inputs"].prompt == ["make the image move"] + assert seen["request_inputs"].negative_prompt == ["jitter"] + assert seen["kwargs"]["image"] is image + assert seen["kwargs"]["noise_scale"] == 0.5 + + def test_denoise_timestep_kwargs_masks_video_only(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + pipe = object.__new__(LTX23ImageToVideoPipeline) + ts = torch.tensor([2.0, 4.0]) + denoise_ctx = SimpleNamespace( + conditioning_mask=torch.tensor([[1.0, 0.0]]), + conditioning_mask_for_model=torch.tensor([[1.0, 0.0], [0.0, 1.0]]), + ) + + kwargs = pipe._denoise_timestep_kwargs( + ts, + SimpleNamespace(cfg_parallel_ready=False), + denoise_ctx, + ) + + torch.testing.assert_close(kwargs["timestep"], torch.tensor([[0.0, 2.0], [4.0, 0.0]])) + torch.testing.assert_close(kwargs["audio_timestep"], ts) + torch.testing.assert_close(kwargs["sigma"], ts) + + +class TestLTX23ImageToVideoPipeline: + def test_ltx23_i2v_pipeline_reuses_ltx23_semantics(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + assert issubclass(LTX23ImageToVideoPipeline, LTX23Pipeline) + assert LTX23ImageToVideoPipeline.support_image_input is True + + def test_ltx23_i2v_rejects_multi_image_prompt_list(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + image = object() + + assert LTX23ImageToVideoPipeline._resolve_single_prompt_image([image]) is image + with pytest.raises(ValueError, match="exactly one image per prompt"): + LTX23ImageToVideoPipeline._resolve_single_prompt_image([object(), object()]) + + def test_ltx23_i2v_additional_image_resolution_is_tensor_safe(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + image = torch.zeros(1, 3, 4, 4) + additional = { + "preprocessed_image": None, + "pixel_values": image, + "image": torch.ones_like(image), + } + + assert LTX23ImageToVideoPipeline._resolve_additional_image(additional) is image + + def test_ltx23_i2v_packed_latents_are_not_noised(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video as ltx23_i2v + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + pipe = object.__new__(LTX23ImageToVideoPipeline) + torch.nn.Module.__init__(pipe) + pipe.vae_spatial_compression_ratio = 1 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + + def fake_randn_tensor(shape, generator=None, device=None, dtype=None): + raise AssertionError("packed I2V latents should not be noised") + + monkeypatch.setattr(ltx23_i2v, "randn_tensor", fake_randn_tensor) + + latents = torch.tensor([[[10.0, 11.0], [20.0, 21.0], [30.0, 31.0]]]) + + out, conditioning_mask = pipe.prepare_latents( + image=None, + batch_size=1, + num_channels_latents=2, + height=1, + width=1, + num_frames=3, + noise_scale=1.0, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) + torch.testing.assert_close(out, latents) + + def test_ltx23_i2v_5d_latents_noise_preserves_conditioning_frame(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.pipeline_ltx2 as ltx2 + import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video as ltx23_i2v + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + pipe = object.__new__(LTX23ImageToVideoPipeline) + torch.nn.Module.__init__(pipe) + pipe.vae_spatial_compression_ratio = 1 + pipe.vae_temporal_compression_ratio = 1 + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + pipe.vae = SimpleNamespace( + latents_mean=torch.zeros(2), + latents_std=torch.ones(2), + config=SimpleNamespace(scaling_factor=1.0), + ) + + def fake_randn_tensor(shape, generator=None, device=None, dtype=None): + return torch.ones(shape, device=device, dtype=dtype) + + monkeypatch.setattr(ltx23_i2v, "randn_tensor", fake_randn_tensor) + monkeypatch.setattr(ltx2, "randn_tensor", fake_randn_tensor) + + latents = torch.tensor([[[[[10.0]], [[20.0]], [[30.0]]], [[[11.0]], [[21.0]], [[31.0]]]]]) + + out, conditioning_mask = pipe.prepare_latents( + image=None, + batch_size=1, + num_channels_latents=2, + height=1, + width=1, + num_frames=3, + noise_scale=1.0, + dtype=torch.float32, + device=torch.device("cpu"), + latents=latents, + ) + + torch.testing.assert_close(conditioning_mask, torch.tensor([[1.0, 0.0, 0.0]])) + torch.testing.assert_close(out, torch.tensor([[[10.0, 11.0], [1.0, 1.0], [1.0, 1.0]]])) + + def test_ltx23_i2v_video_step_preserves_conditioning_frame(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + pipe = object.__new__(LTX23ImageToVideoPipeline) + torch.nn.Module.__init__(pipe) + pipe.transformer_spatial_patch_size = 1 + pipe.transformer_temporal_patch_size = 1 + + class FakeScheduler: + def step(self, noise_pred, t, latents, return_dict=False): + return (latents + noise_pred + t,) + + pipe.scheduler = FakeScheduler() + latents = torch.tensor([[[1.0], [2.0], [3.0]]]) + noise_pred = torch.full_like(latents, 10.0) + + out = pipe._step_video_latents_i2v( + noise_pred, + latents, + torch.tensor(0.5), + latent_num_frames=3, + latent_height=1, + latent_width=1, + ) + + torch.testing.assert_close(out[:, :1], latents[:, :1]) + torch.testing.assert_close(out[:, 1:], latents[:, 1:] + noise_pred[:, 1:] + 0.5) + + +class TestLTX23ImageToVideoModule: + """Test that pipeline_ltx2_3_image2video.py exposes I2V entry points.""" + + def test_i2v_classes_importable(self): + """I2V classes must be importable from the implementation module.""" + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + + assert LTX23ImageToVideoPipeline is not None + + def test_post_process_func_importable(self): + """get_ltx2_post_process_func must be importable for registry lookup.""" + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import get_ltx2_post_process_func + + assert callable(get_ltx2_post_process_func) + + def test_i2v_class_matches_package_export(self): + """Package export must point at the I2V implementation module.""" + from vllm_omni.diffusion.models.ltx2 import LTX23ImageToVideoPipeline as PackageExported + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import ( + LTX23ImageToVideoPipeline as ModuleExported, + ) + + assert PackageExported is ModuleExported diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py index 96051416b..25aae4510 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx2_3_pipeline.py @@ -38,6 +38,279 @@ def _make_ltx23_pipeline(sequence_parallel_size: int = 1): return pipeline +def _make_ltx23_request_pipe(cls): + pipe = object.__new__(cls) + torch.nn.Module.__init__(pipe) + pipe.device = torch.device("cpu") + pipe.tokenizer_max_length = 99 + return pipe + + +def _resolve_request_inputs_for_test(pipe, req): + return pipe._resolve_request_inputs( + req, + prompt=None, + negative_prompt=None, + height=None, + width=None, + num_frames=None, + frame_rate=None, + num_inference_steps=None, + timesteps=None, + guidance_scale=4.0, + num_videos_per_prompt=1, + generator=None, + latents=None, + audio_latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + decode_timestep=0.0, + decode_noise_scale=None, + output_type="np", + max_sequence_length=None, + ) + + +class TestLTX23RequestParsing: + def test_t2v_and_i2v_share_request_input_resolution(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + prompt_embeds = torch.tensor([[1.0, 2.0]]) + negative_prompt_embeds = torch.tensor([[3.0, 4.0]]) + prompt_attention_mask = torch.tensor([True, False]) + negative_attention_mask = torch.tensor([False, True]) + video_latents = torch.ones(1, 2, 3) + audio_latents = torch.zeros(1, 2, 3) + generator = torch.Generator().manual_seed(123) + + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "shared prompt", + "negative_prompt": "shared negative", + "additional_information": { + "prompt_embeds": prompt_embeds, + "negative_prompt_embeds": negative_prompt_embeds, + "attention_mask": prompt_attention_mask, + "negative_attention_mask": negative_attention_mask, + }, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + frame_rate=12.5, + num_inference_steps=1, + num_outputs_per_prompt=2, + guidance_scale=6.0, + generator=generator, + latents=video_latents, + extra_args={"audio_latents": audio_latents}, + decode_timestep=[0.1], + decode_noise_scale=[0.2], + output_type="latent", + max_sequence_length=17, + ), + request_id="ltx23-shared-request-inputs", + ) + ] + ) + + resolved_t2v = _resolve_request_inputs_for_test( + _make_ltx23_request_pipe(LTX23Pipeline), + req, + ) + resolved_i2v = _resolve_request_inputs_for_test( + _make_ltx23_request_pipe(LTX23ImageToVideoPipeline), + req, + ) + + assert resolved_i2v.prompt == resolved_t2v.prompt is None + assert resolved_i2v.negative_prompt == resolved_t2v.negative_prompt is None + assert resolved_i2v.height == resolved_t2v.height == 384 + assert resolved_i2v.width == resolved_t2v.width == 512 + assert resolved_i2v.num_frames == resolved_t2v.num_frames == 25 + assert resolved_i2v.frame_rate == resolved_t2v.frame_rate == 12.5 + assert resolved_i2v.num_inference_steps == resolved_t2v.num_inference_steps == 2 + assert resolved_i2v.guidance_scale == resolved_t2v.guidance_scale == 6.0 + assert resolved_i2v.num_videos_per_prompt == resolved_t2v.num_videos_per_prompt == 2 + assert isinstance(resolved_i2v.generator, list) + assert isinstance(resolved_t2v.generator, list) + assert [gen.initial_seed() for gen in resolved_i2v.generator] == [123, 123] + assert [gen.initial_seed() for gen in resolved_t2v.generator] == [123, 123] + assert resolved_i2v.decode_timestep == resolved_t2v.decode_timestep == [0.1] + assert resolved_i2v.decode_noise_scale == resolved_t2v.decode_noise_scale == [0.2] + assert resolved_i2v.output_type == resolved_t2v.output_type == "latent" + assert resolved_i2v.max_sequence_length == resolved_t2v.max_sequence_length == 17 + torch.testing.assert_close(resolved_i2v.latents, video_latents) + torch.testing.assert_close(resolved_t2v.latents, video_latents) + torch.testing.assert_close(resolved_i2v.audio_latents, audio_latents) + torch.testing.assert_close(resolved_t2v.audio_latents, audio_latents) + torch.testing.assert_close(resolved_i2v.prompt_embeds, torch.stack([prompt_embeds])) + torch.testing.assert_close(resolved_t2v.prompt_embeds, torch.stack([prompt_embeds])) + torch.testing.assert_close(resolved_i2v.negative_prompt_embeds, torch.stack([negative_prompt_embeds])) + torch.testing.assert_close(resolved_t2v.negative_prompt_embeds, torch.stack([negative_prompt_embeds])) + torch.testing.assert_close(resolved_i2v.prompt_attention_mask, torch.stack([prompt_attention_mask])) + torch.testing.assert_close(resolved_t2v.prompt_attention_mask, torch.stack([prompt_attention_mask])) + torch.testing.assert_close( + resolved_i2v.negative_prompt_attention_mask, + torch.stack([negative_attention_mask]), + ) + torch.testing.assert_close( + resolved_t2v.negative_prompt_attention_mask, + torch.stack([negative_attention_mask]), + ) + + def test_request_input_resolution_rejects_mixed_precomputed_prompt_fields(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx23_request_pipe(LTX23Pipeline) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt={ + "prompt": "with embeds", + "additional_information": {"prompt_embeds": torch.tensor([[1.0]])}, + }, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-mixed-precomputed-fields-0", + ), + OmniDiffusionRequest( + prompt={"prompt": "without embeds"}, + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + num_inference_steps=2, + ), + request_id="ltx23-mixed-precomputed-fields-1", + ), + ] + ) + + with pytest.raises(ValueError, match="mix of provided and missing prompt_embeds"): + _resolve_request_inputs_for_test(pipe, req) + + +class TestLTX23ForwardStages: + def test_encode_prompt_repeats_precomputed_embeds_for_num_outputs(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + + pipe = _make_ltx23_request_pipe(LTX23Pipeline) + prompt_embeds = torch.tensor([[[1.0], [2.0]], [[3.0], [4.0]]]) + negative_prompt_embeds = torch.tensor([[[-1.0], [-2.0]], [[-3.0], [-4.0]]]) + prompt_attention_mask = torch.tensor([[True, False], [False, True]]) + negative_prompt_attention_mask = torch.tensor([[False, False], [True, True]]) + + ( + repeated_prompt_embeds, + repeated_prompt_attention_mask, + repeated_negative_prompt_embeds, + repeated_negative_prompt_attention_mask, + ) = pipe.encode_prompt( + prompt=None, + do_classifier_free_guidance=True, + num_videos_per_prompt=2, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + device=torch.device("cpu"), + ) + + torch.testing.assert_close( + repeated_prompt_embeds, + torch.tensor([[[1.0], [2.0]], [[1.0], [2.0]], [[3.0], [4.0]], [[3.0], [4.0]]]), + ) + torch.testing.assert_close( + repeated_negative_prompt_embeds, + torch.tensor([[[-1.0], [-2.0]], [[-1.0], [-2.0]], [[-3.0], [-4.0]], [[-3.0], [-4.0]]]), + ) + torch.testing.assert_close( + repeated_prompt_attention_mask, + torch.tensor([[True, False], [True, False], [False, True], [False, True]]), + ) + torch.testing.assert_close( + repeated_negative_prompt_attention_mask, + torch.tensor([[False, False], [False, False], [True, True], [True, True]]), + ) + + def test_t2v_forward_delegates_to_shared_forward_impl(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline + from vllm_omni.diffusion.request import OmniDiffusionRequest + from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + pipe = _make_ltx23_request_pipe(LTX23Pipeline) + req = DiffusionRequestBatch( + [ + OmniDiffusionRequest( + prompt="stage refactor prompt", + sampling_params=OmniDiffusionSamplingParams( + height=384, + width=512, + num_frames=25, + frame_rate=16, + num_inference_steps=3, + guidance_scale=4.5, + output_type="latent", + ), + request_id="ltx23-t2v-forward-stage-delegation", + ) + ] + ) + sigmas = [1.0, 0.5] + timesteps = [1000, 500] + attention_kwargs = {"scale": 1.0} + seen = {} + + def fake_forward_impl(req_arg, request_inputs, **kwargs): + seen["req"] = req_arg + seen["request_inputs"] = request_inputs + seen["kwargs"] = kwargs + return ["delegated"] + + object.__setattr__(pipe, "_forward_impl", fake_forward_impl) + + output = pipe.forward( + req, + sigmas=sigmas, + timesteps=timesteps, + noise_scale=0.25, + attention_kwargs=attention_kwargs, + ) + + assert output == ["delegated"] + assert seen["req"] is req + assert seen["request_inputs"].height == 384 + assert seen["request_inputs"].width == 512 + assert seen["request_inputs"].num_frames == 25 + assert seen["request_inputs"].frame_rate == 16.0 + assert seen["request_inputs"].guidance_scale == 4.5 + assert seen["request_inputs"].output_type == "latent" + assert seen["kwargs"] == { + "noise_scale": 0.25, + "sigmas": sigmas, + "timesteps": timesteps, + "attention_kwargs": attention_kwargs, + } + + class TestPipelineIndependence: """Verify LTX23Pipeline is fully independent from LTX2Pipeline.""" @@ -65,13 +338,6 @@ class TestPipelineIndependence: assert issubclass(LTX23Pipeline, ProgressBarMixin) - def test_ltx23_pipeline_has_cfg_parallel_mixin(self): - """LTX23Pipeline must use the shared CFG parallel implementation.""" - from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - 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 @@ -105,661 +371,40 @@ class TestPipelineIndependence: assert issubclass(LTX23Pipeline, DiffusionPipelineProfilerMixin) -class TestLTX23VaeDecodeParallel: - """Test LTX-2.3 video VAE tiled parallel helpers without loading weights.""" +class TestLTX23DecodeConditioning: + def test_decode_conditioning_expands_per_prompt_values_to_effective_batch(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _expand_per_prompt_decode_value - 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 _expand_per_prompt_decode_value( + [0.1, 0.2], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.1, 0.1, 0.2, 0.2] + assert _expand_per_prompt_decode_value( + [0.3], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.3, 0.3, 0.3, 0.3] + assert _expand_per_prompt_decode_value( + [0.1, 0.2, 0.3, 0.4], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", + ) == [0.1, 0.2, 0.3, 0.4] - assert issubclass(DistributedAutoencoderKLLTX2Video, DistributedVaeMixin) - assert not hasattr(DistributedAutoencoderKLLTX2Video, "patch_split") + def test_decode_conditioning_rejects_ambiguous_lengths(self): + from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import _expand_per_prompt_decode_value - 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], - ] + with pytest.raises(ValueError, match="decode_timestep"): + _expand_per_prompt_decode_value( + [0.1, 0.2, 0.3], + prompt_batch_size=2, + effective_batch_size=4, + field_name="decode_timestep", ) - 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.""" - - def test_combine_cfg_noise_matches_x0_space_formula(self): - import torch - - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline - - pipe = object.__new__(LTX23Pipeline) - video_sample = torch.tensor([[[1.0, -2.0]]]) - audio_sample = torch.tensor([[[0.5, 3.0]]]) - video_pos = torch.tensor([[[0.2, -0.3]]]) - video_neg = torch.tensor([[[-0.4, 0.1]]]) - audio_pos = torch.tensor([[[0.7, -0.2]]]) - audio_neg = torch.tensor([[[0.1, 0.4]]]) - video_sigma = torch.tensor(0.25) - audio_sigma = torch.tensor(0.5) - scale = 4.0 - - video_combined, audio_combined = pipe.combine_cfg_noise( - (video_pos, audio_pos), - (video_neg, audio_neg), - scale, - video_latents=video_sample, - audio_latents=audio_sample, - video_sigma=video_sigma, - audio_sigma=audio_sigma, - ) - - x0_video_cond = video_sample - video_pos * video_sigma - x0_video_uncond = video_sample - video_neg * video_sigma - x0_video_guided = x0_video_cond + (scale - 1) * (x0_video_cond - x0_video_uncond) - expected_video = (video_sample - x0_video_guided) / video_sigma - - x0_audio_cond = audio_sample - audio_pos * audio_sigma - x0_audio_uncond = audio_sample - audio_neg * audio_sigma - x0_audio_guided = x0_audio_cond + (scale - 1) * (x0_audio_cond - x0_audio_uncond) - expected_audio = (audio_sample - x0_audio_guided) / audio_sigma - assert torch.allclose(video_combined, expected_video) - assert torch.allclose(audio_combined, expected_audio) - - def test_two_rank_cfg_parallel_smoke_uses_rank_local_branch_and_x0_formula(self, monkeypatch): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 - - pipe = object.__new__(ltx23.LTX23Pipeline) - video_sample = torch.tensor([[[1.0, -2.0]]]) - audio_sample = torch.tensor([[[0.5, 3.0, -1.0]]]) - video_pos = torch.tensor([[[0.2, -0.3]]]) - video_neg = torch.tensor([[[-0.4, 0.1]]]) - audio_pos = torch.tensor([[[0.7, -0.2, 0.3]]]) - audio_neg = torch.tensor([[[0.1, 0.4, -0.5]]]) - video_sigma = torch.tensor(0.25) - audio_sigma = torch.tensor(0.5) - scale = 4.0 - - class FakeCfgGroup: - def all_gather(self, tensor, separate_tensors=True): - assert separate_tensors - if tensor.shape == video_pos.shape: - return [video_pos, video_neg] - return [audio_pos, audio_neg] - - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) - monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) - - expected_video = ltx23.LTX23Pipeline._combine_x0_space_cfg( - video_sample, - video_pos, - video_neg, - video_sigma, - scale, - ) - expected_audio = ltx23.LTX23Pipeline._combine_x0_space_cfg( - audio_sample, - audio_pos, - audio_neg, - audio_sigma, - scale, - ) - - for rank, expected_branch in ((0, "positive"), (1, "negative")): - calls = [] - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda rank=rank: rank) - - def fake_predict_noise(**kwargs): - calls.append(kwargs["branch"]) - if kwargs["branch"] == "positive": - return video_pos, audio_pos - return video_neg, audio_neg - - object.__setattr__(pipe, "predict_noise", fake_predict_noise) - video_combined, audio_combined = pipe.predict_noise_with_parallel_cfg( - true_cfg_scale=scale, - positive_kwargs={"branch": "positive"}, - negative_kwargs={"branch": "negative"}, - cfg_normalize=False, - video_latents=video_sample, - audio_latents=audio_sample, - video_sigma=video_sigma, - audio_sigma=audio_sigma, - ) - - assert calls == [expected_branch] - torch.testing.assert_close(video_combined, expected_video) - torch.testing.assert_close(audio_combined, expected_audio) - - assert "_cfg_video_latents" not in pipe.__dict__ - assert "_cfg_audio_latents" not in pipe.__dict__ - - -class TestCFGParallelForwardPath: - """Test the LTX-2.3 CFG-parallel denoising path without loading model weights.""" - - def test_forward_collates_request_prompt_embeds_and_mask_aliases(self, monkeypatch): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - pipe = object.__new__(ltx23.LTX23Pipeline) - torch.nn.Module.__init__(pipe) - pipe.device = torch.device("cpu") - pipe.tokenizer_max_length = 4 - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 1) - - class StopAtEncodePromptError(Exception): - pass - - captured = {} - - def fake_encode_prompt(**kwargs): - captured.update(kwargs) - raise StopAtEncodePromptError - - object.__setattr__(pipe, "encode_prompt", fake_encode_prompt) - - prompt_embeds_a = torch.zeros(2, 3) - prompt_embeds_b = torch.ones(2, 3) - negative_prompt_embeds_a = torch.full((2, 3), 2.0) - negative_prompt_embeds_b = torch.full((2, 3), 3.0) - prompt_attention_mask_a = torch.tensor([True, True]) - prompt_attention_mask_b = torch.tensor([True, False]) - negative_attention_mask_a = torch.tensor([False, True]) - negative_attention_mask_b = torch.tensor([False, False]) - - requests = [ - OmniDiffusionRequest( - prompt={ - "prompt": "prompt-a", - "negative_prompt": "negative-a", - "prompt_embeds": prompt_embeds_a, - "negative_prompt_embeds": negative_prompt_embeds_a, - "prompt_attention_mask": prompt_attention_mask_a, - "negative_prompt_attention_mask": negative_attention_mask_a, - }, - sampling_params=OmniDiffusionSamplingParams( - height=32, - width=32, - num_frames=1, - frame_rate=1.0, - num_inference_steps=2, - ), - request_id="ltx23-prompt-local-a", - ), - OmniDiffusionRequest( - prompt={ - "prompt": "prompt-b", - "negative_prompt": "negative-b", - "prompt_embeds": prompt_embeds_b, - "negative_prompt_embeds": negative_prompt_embeds_b, - "attention_mask": prompt_attention_mask_b, - "negative_attention_mask": negative_attention_mask_b, - }, - sampling_params=OmniDiffusionSamplingParams( - height=32, - width=32, - num_frames=1, - frame_rate=1.0, - num_inference_steps=2, - ), - request_id="ltx23-prompt-local-b", - ), - ] - - with pytest.raises(StopAtEncodePromptError): - pipe.forward(DiffusionRequestBatch(requests=requests)) - - assert captured["prompt"] is None - assert captured["negative_prompt"] is None - torch.testing.assert_close( - captured["prompt_embeds"], - torch.stack([prompt_embeds_a, prompt_embeds_b], dim=0), - ) - torch.testing.assert_close( - captured["negative_prompt_embeds"], - torch.stack([negative_prompt_embeds_a, negative_prompt_embeds_b], dim=0), - ) - torch.testing.assert_close( - captured["prompt_attention_mask"], - torch.stack([prompt_attention_mask_a, prompt_attention_mask_b], dim=0), - ) - torch.testing.assert_close( - captured["negative_prompt_attention_mask"], - torch.stack([negative_attention_mask_a, negative_attention_mask_b], dim=0), - ) - - @pytest.mark.parametrize(("cfg_rank", "expected_prompt_value"), [(0, 1.0), (1, 0.0)]) - @pytest.mark.parametrize( - ("frame_rate_input", "audio_sampling_rate", "expected_frame_rate"), - [(1.0, 1, 1.0), (None, 24, 24.0)], - ) - def test_forward_cfg_parallel_steps_video_and_audio_scheduler( - self, - monkeypatch, - cfg_rank, - expected_prompt_value, - frame_rate_input, - audio_sampling_rate, - expected_frame_rate, - ): - from vllm_omni.diffusion.models.ltx2 import pipeline_ltx2_3 as ltx23 - from vllm_omni.diffusion.request import OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - pipe = object.__new__(ltx23.LTX23Pipeline) - torch.nn.Module.__init__(pipe) - pipe.device = torch.device("cpu") - pipe.tokenizer_max_length = 1 - pipe.vae_spatial_compression_ratio = 32 - pipe.vae_temporal_compression_ratio = 1 - pipe.transformer_spatial_patch_size = 1 - pipe.transformer_temporal_patch_size = 1 - pipe.audio_sampling_rate = audio_sampling_rate - pipe.audio_hop_length = 1 - pipe.audio_vae_temporal_compression_ratio = 1 - pipe.audio_vae_mel_compression_ratio = 1 - pipe.od_config = SimpleNamespace(parallel_config=SimpleNamespace(sequence_parallel_size=1)) - pipe.tokenizer = SimpleNamespace(padding_side="left") - pipe.vae = SimpleNamespace( - latents_mean=torch.zeros(2), - latents_std=torch.ones(2), - config=SimpleNamespace(scaling_factor=1.0), - ) - pipe.audio_vae = SimpleNamespace( - latents_mean=torch.zeros(2), - latents_std=torch.ones(2), - config=SimpleNamespace(mel_bins=2, latent_channels=1), - ) - - video_pos = torch.tensor([[[0.2, -0.3]]]) - video_neg = torch.tensor([[[-0.4, 0.1]]]) - audio_pos = torch.tensor([[[0.7, -0.2]]]) - audio_neg = torch.tensor([[[0.1, 0.4]]]) - - class FakeCfgGroup: - def all_gather(self, tensor, separate_tensors=True): - assert separate_tensors - if torch.equal(tensor, video_pos) or torch.equal(tensor, video_neg): - return [video_pos, video_neg] - if torch.equal(tensor, audio_pos) or torch.equal(tensor, audio_neg): - return [audio_pos, audio_neg] - raise AssertionError(f"Unexpected gathered tensor: {tensor}") - - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_world_size", lambda: 2) - monkeypatch.setattr(ltx23, "get_classifier_free_guidance_rank", lambda: cfg_rank) - monkeypatch.setattr(ltx23, "get_cfg_group", lambda: FakeCfgGroup()) - - def fake_retrieve_timesteps(scheduler, num_inference_steps, device, timesteps, sigmas=None, mu=None): - scheduler.sigmas = torch.tensor([0.25, 0.25], device=device) - return torch.tensor([1.0, 0.5], device=device), 2 - - monkeypatch.setattr(ltx23, "retrieve_timesteps", fake_retrieve_timesteps) - - class FakeScheduler: - def __init__(self, name="video", calls=None): - self.name = name - self.calls = [] if calls is None else calls - self.config = { - "max_image_seq_len": 4096, - "base_image_seq_len": 1024, - "base_shift": 0.95, - "max_shift": 2.05, - } - self.sigmas = torch.tensor([0.25, 0.25]) - - def __deepcopy__(self, memo): - return FakeScheduler("audio", self.calls) - - def step(self, noise_pred, t, latents, return_dict=False, generator=None): - self.calls.append((self.name, noise_pred.clone(), t.clone(), latents.clone())) - return (latents - noise_pred,) - - class FakeConnectors: - def to(self, device): - return self - - def __call__(self, prompt_embeds, prompt_attention_mask, padding_side): - assert padding_side == "left" - assert prompt_embeds.shape[0] == 2 - return prompt_embeds, prompt_embeds, prompt_attention_mask - - rope_video_fps: list[float] = [] - - class FakeRope: - def prepare_video_coords(self, batch_size, num_frames, height, width, device, fps): - rope_video_fps.append(fps) - return torch.zeros(batch_size, num_frames * height * width, 3, device=device) - - def prepare_audio_coords(self, batch_size, num_frames, device): - return torch.zeros(batch_size, num_frames, 1, device=device) - - class FakeTransformer: - def __init__(self): - self.config = SimpleNamespace(in_channels=2) - self.rope = FakeRope() - self.audio_rope = FakeRope() - self.calls = [] - - def __call__(self, **kwargs): - self.calls.append(kwargs) - expected_prompt = torch.full((1, 1, 1), expected_prompt_value) - torch.testing.assert_close(kwargs["encoder_hidden_states"], expected_prompt) - torch.testing.assert_close(kwargs["audio_encoder_hidden_states"], expected_prompt) - assert kwargs["hidden_states"].shape == (1, 1, 2) - assert kwargs["audio_hidden_states"].shape == (1, 1, 2) - if cfg_rank == 0: - return video_pos, audio_pos - return video_neg, audio_neg - - class DummyProgress: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def update(self): - pass - - pipe.scheduler = FakeScheduler() - pipe.connectors = FakeConnectors() - pipe.transformer = FakeTransformer() - object.__setattr__(pipe, "progress_bar", lambda total: DummyProgress()) - - def fake_encode_prompt(**kwargs): - return ( - torch.ones(1, 1, 1), - torch.ones(1, 1, dtype=torch.bool), - torch.zeros(1, 1, 1), - torch.ones(1, 1, dtype=torch.bool), - ) - - object.__setattr__(pipe, "encode_prompt", fake_encode_prompt) - - video_latents = torch.tensor([[[1.0, -2.0]]]) - audio_latents = torch.tensor([[[0.5, 3.0]]]) - req = OmniDiffusionRequest( - prompt={"prompt": "prompt", "negative_prompt": "negative"}, - sampling_params=OmniDiffusionSamplingParams( - height=32, - width=32, - num_frames=1, - frame_rate=frame_rate_input, - num_inference_steps=2, - guidance_scale=4.0, - latents=video_latents, - audio_latents=audio_latents, - output_type="latent", - ), - request_id="ltx23-cfg-parallel-forward-test", - ) - - output = pipe.forward(DiffusionRequestBatch(requests=[req]))[0] - - expected_video_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( - video_latents, - video_pos, - video_neg, - pipe.scheduler.sigmas[0], - 4.0, - ) - expected_audio_noise = ltx23.LTX23Pipeline._combine_x0_space_cfg( - audio_latents, - audio_pos, - audio_neg, - pipe.scheduler.sigmas[0], - 4.0, - ) - scheduler_call_names = [call[0] for call in pipe.scheduler.calls] - assert scheduler_call_names == ["video", "audio", "video", "audio"] - assert len(pipe.transformer.calls) == 2 - torch.testing.assert_close(pipe.scheduler.calls[0][1], expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[1][1], expected_audio_noise) - torch.testing.assert_close(pipe.scheduler.calls[2][1], expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[3][1], expected_audio_noise) - torch.testing.assert_close(pipe.scheduler.calls[2][3], video_latents - expected_video_noise) - torch.testing.assert_close(pipe.scheduler.calls[3][3], audio_latents - expected_audio_noise) - - video_out, audio_out = output.output - torch.testing.assert_close(video_out, (video_latents - 2 * expected_video_noise).reshape(1, 2, 1, 1, 1)) - torch.testing.assert_close(audio_out, (audio_latents - 2 * expected_audio_noise).reshape(1, 1, 1, 2)) - - # fps regression guard: an omitted request fps (frame_rate_input=None) must resolve - # to the model's own 24.0 default, not crash on None; a provided rate is passed through. - assert rope_video_fps - assert all(fps == expected_frame_rate for fps in rope_video_fps) - class TestRegistryIntegration: """Verify all LTX-2.3 pipeline variants are registered.""" @@ -814,15 +459,6 @@ class TestRegistryIntegration: for name in expected: assert name not in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS" - def test_ltx2_transformer_has_dit_cache_config(self): - """Ensure LTX2 has a Cache DiT adapter config and that it uses separate CFG.""" - from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig - from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel - - adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config") - assert isinstance(adapter_config, CacheDiTAdapterConfig) - assert adapter_config.has_separate_cfg - class TestVocoderSampleRateDetection: """Test _detect_vocoder_output_sample_rate logic.""" @@ -912,31 +548,6 @@ class TestPostProcessFunction: assert "audio_sample_rate" not in result -class TestReExportModule: - """Test that pipeline_ltx2_3_image2video.py correctly re-exports.""" - - def test_i2v_classes_importable(self): - """I2V classes must be importable from the re-export module.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline - - assert LTX23ImageToVideoPipeline is not None - - def test_post_process_func_importable(self): - """get_ltx2_post_process_func must be importable from re-export module.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import get_ltx2_post_process_func - - assert callable(get_ltx2_post_process_func) - - def test_i2v_classes_are_same_as_direct_import(self): - """Re-exported classes must be the same objects as direct imports.""" - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23ImageToVideoPipeline as Direct - from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import ( - LTX23ImageToVideoPipeline as ReExported, - ) - - assert Direct is ReExported - - class TestInitExports: """Test that __init__.py exports all LTX-2.3 classes.""" diff --git a/tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py b/tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py new file mode 100644 index 000000000..9b53bb4f8 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_3_vae_tiling.py @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""Unit tests for LTX-2.3 VAE tiling and distributed decode behavior.""" + +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestLTX23OutputRank: + def test_single_process_rank_is_output_rank(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 as ltx23 + + monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: False) + + assert ltx23._is_output_rank() is True + assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: False)) is True + + def test_non_output_rank_skips_decode_unless_vae_decode_is_distributed(self, monkeypatch): + import vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 as ltx23 + + monkeypatch.setattr(ltx23.torch.distributed, "is_initialized", lambda: True) + monkeypatch.setattr(ltx23.torch.distributed, "get_rank", lambda: 1) + + assert ltx23._is_output_rank() is False + assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: False)) is False + assert ltx23._should_decode_video_on_rank(SimpleNamespace(is_distributed_enabled=lambda: True)) is True + + +class TestLTX23VaeDistributedDecode: + """Test LTX-2.3 distributed VAE 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_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 TestLTX23VaeTiling: + """Test LTX-2.3 video VAE tile helpers without loading weights.""" + + 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" diff --git a/vllm_omni/diffusion/models/ltx2/__init__.py b/vllm_omni/diffusion/models/ltx2/__init__.py index bf57d3f9e..76a26b5b0 100644 --- a/vllm_omni/diffusion/models/ltx2/__init__.py +++ b/vllm_omni/diffusion/models/ltx2/__init__.py @@ -10,10 +10,8 @@ from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import ( get_ltx2_post_process_func, load_transformer_config, ) -from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import ( - LTX23ImageToVideoPipeline, - LTX23Pipeline, -) +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3 import LTX23Pipeline +from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_3_image2video import LTX23ImageToVideoPipeline from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import ( LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline, diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py index 9183065dc..45be23296 100644 --- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py +++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3.py @@ -19,6 +19,7 @@ import json import os from collections.abc import Iterable from contextlib import nullcontext +from dataclasses import dataclass from typing import Any, ClassVar import numpy as np @@ -68,6 +69,26 @@ def _get_audio_latents_from_sampling(sampling: Any) -> torch.Tensor | None: return sampling.extra_args.get("audio_latents") +def _is_output_rank() -> bool: + return not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0 + + +def _vae_decode_needs_all_ranks(vae: Any) -> bool: + if not torch.distributed.is_initialized(): + return False + is_distributed_enabled = getattr(vae, "is_distributed_enabled", None) + if not callable(is_distributed_enabled): + return False + try: + return bool(is_distributed_enabled()) + except Exception: + return False + + +def _should_decode_video_on_rank(vae: Any) -> bool: + return _is_output_rank() or _vae_decode_needs_all_ranks(vae) + + # Try to import LTX2VocoderWithBWE (diffusers >= 0.38.0) try: from diffusers.pipelines.ltx2.vocoder import LTX2VocoderWithBWE @@ -75,6 +96,87 @@ except ImportError: LTX2VocoderWithBWE = None +@dataclass +class _LTX23RequestInputs: + prompt: str | list[str] | None + negative_prompt: str | list[str] | None + height: int + width: int + num_frames: int + frame_rate: float + num_inference_steps: int + guidance_scale: float + num_videos_per_prompt: int + generator: torch.Generator | list[torch.Generator] | None + latents: torch.Tensor | None + audio_latents: torch.Tensor | None + prompt_embeds: torch.Tensor | None + negative_prompt_embeds: torch.Tensor | None + prompt_attention_mask: torch.Tensor | None + negative_prompt_attention_mask: torch.Tensor | None + decode_timestep: float | list[float] + decode_noise_scale: float | list[float] | None + output_type: str + max_sequence_length: int + + +@dataclass +class _LTX23PromptContext: + batch_size: int + connector_prompt_embeds: torch.Tensor + connector_audio_prompt_embeds: torch.Tensor + connector_attention_mask: torch.Tensor + positive_connector_prompt_embeds: torch.Tensor + positive_connector_audio_prompt_embeds: torch.Tensor + positive_connector_attention_mask: torch.Tensor + negative_connector_prompt_embeds: torch.Tensor | None + negative_connector_audio_prompt_embeds: torch.Tensor | None + negative_connector_attention_mask: torch.Tensor | None + + +@dataclass +class _LTX23ForwardContext: + req: DiffusionRequestBatch + request_inputs: _LTX23RequestInputs + prompt_context: _LTX23PromptContext + device: torch.device + cfg_parallel_ready: bool + attention_kwargs: dict[str, Any] | None + latent_num_frames: int + latent_height: int + latent_width: int + latent_mel_bins: int + original_audio_num_frames: int + padded_audio_num_frames: int + timesteps: torch.Tensor + audio_scheduler: Any + video_audio_scheduler: Any + + @property + def batch_size(self) -> int: + return self.prompt_context.batch_size + + @property + def num_videos_per_prompt(self) -> int: + return self.request_inputs.num_videos_per_prompt + + +@dataclass +class _LTX23DenoiseContext: + latents: torch.Tensor + audio_latents: torch.Tensor + video_coords: torch.Tensor + audio_coords: torch.Tensor + conditioning_mask: torch.Tensor | None = None + conditioning_mask_for_model: torch.Tensor | None = None + + +def _repeat_prompt_tensor_for_outputs(tensor: torch.Tensor, num_videos_per_prompt: int) -> torch.Tensor: + if num_videos_per_prompt == 1: + return tensor + return tensor.repeat_interleave(num_videos_per_prompt, dim=0) + + def _detect_vocoder_output_sample_rate(model: str) -> int | None: """Detect the vocoder output sample rate from vocoder/config.json. @@ -121,6 +223,58 @@ def get_ltx2_post_process_func(od_config: OmniDiffusionConfig): return post_process_func +def _expand_per_prompt_decode_value( + value: float | list[float], + *, + prompt_batch_size: int, + effective_batch_size: int, + field_name: str, +) -> list[float]: + if not isinstance(value, list): + return [value] * effective_batch_size + if len(value) == 1: + return value * effective_batch_size + if len(value) == effective_batch_size: + return value + if prompt_batch_size > 0 and len(value) == prompt_batch_size and effective_batch_size % prompt_batch_size == 0: + repeats = effective_batch_size // prompt_batch_size + return [item for item in value for _ in range(repeats)] + raise ValueError( + f"`{field_name}` must have length 1, prompt batch size ({prompt_batch_size}), or effective batch size" + f" ({effective_batch_size}); got {len(value)}." + ) + + +def _prepare_decode_timestep_conditioning( + *, + decode_timestep: float | list[float], + decode_noise_scale: float | list[float] | None, + prompt_batch_size: int, + effective_batch_size: int, + device: torch.device, + dtype: torch.dtype, +) -> tuple[torch.Tensor, torch.Tensor]: + decode_timestep_values = _expand_per_prompt_decode_value( + decode_timestep, + prompt_batch_size=prompt_batch_size, + effective_batch_size=effective_batch_size, + field_name="decode_timestep", + ) + if decode_noise_scale is None: + decode_noise_scale_values = decode_timestep_values + else: + decode_noise_scale_values = _expand_per_prompt_decode_value( + decode_noise_scale, + prompt_batch_size=prompt_batch_size, + effective_batch_size=effective_batch_size, + field_name="decode_noise_scale", + ) + return ( + torch.tensor(decode_timestep_values, device=device, dtype=dtype), + torch.tensor(decode_noise_scale_values, device=device, dtype=dtype)[:, None, None, None, None], + ) + + class LTX23Pipeline( nn.Module, CFGParallelMixin, @@ -353,12 +507,9 @@ class LTX23Pipeline( # [49 x (B, seq, 3840)] -> [B, seq, 3840, 49] -> [B, seq, 188160] prompt_embeds = torch.stack(hidden_states, dim=-1).flatten(2, 3).to(dtype=dtype) - _, seq_len, _ = prompt_embeds.shape - prompt_embeds = prompt_embeds.repeat(1, num_videos_per_prompt, 1) - prompt_embeds = prompt_embeds.view(batch_size * num_videos_per_prompt, seq_len, -1) - prompt_attention_mask = prompt_attention_mask.view(batch_size, -1) - prompt_attention_mask = prompt_attention_mask.repeat(num_videos_per_prompt, 1) + prompt_embeds = _repeat_prompt_tensor_for_outputs(prompt_embeds, num_videos_per_prompt) + prompt_attention_mask = _repeat_prompt_tensor_for_outputs(prompt_attention_mask, num_videos_per_prompt) return prompt_embeds, prompt_attention_mask @@ -384,6 +535,8 @@ class LTX23Pipeline( else: batch_size = prompt_embeds.shape[0] + negative_prompt_embeds_provided = negative_prompt_embeds is not None + if prompt_embeds is None: prompt_embeds, prompt_attention_mask = self._get_gemma_prompt_embeds( prompt=prompt, @@ -392,6 +545,9 @@ class LTX23Pipeline( device=device, dtype=dtype, ) + elif num_videos_per_prompt > 1: + prompt_embeds = _repeat_prompt_tensor_for_outputs(prompt_embeds, num_videos_per_prompt) + prompt_attention_mask = _repeat_prompt_tensor_for_outputs(prompt_attention_mask, num_videos_per_prompt) if do_classifier_free_guidance and negative_prompt_embeds is None: negative_prompt = negative_prompt or "" @@ -416,6 +572,12 @@ class LTX23Pipeline( device=device, dtype=dtype, ) + elif do_classifier_free_guidance and negative_prompt_embeds_provided and num_videos_per_prompt > 1: + negative_prompt_embeds = _repeat_prompt_tensor_for_outputs(negative_prompt_embeds, num_videos_per_prompt) + negative_prompt_attention_mask = _repeat_prompt_tensor_for_outputs( + negative_prompt_attention_mask, + num_videos_per_prompt, + ) return prompt_embeds, prompt_attention_mask, negative_prompt_embeds, negative_prompt_attention_mask @@ -542,6 +704,67 @@ class LTX23Pipeline( return audio_latent_length return provided_latent_length + def _decode_output( + self, + *, + latents: torch.Tensor, + audio_latents: torch.Tensor, + output_type: str, + connector_prompt_embeds: torch.Tensor, + generator: torch.Generator | list[torch.Generator] | None, + device: torch.device, + decode_timestep: float | list[float], + decode_noise_scale: float | list[float] | None, + prompt_batch_size: int, + ) -> DiffusionOutput: + if output_type == "latent": + return DiffusionOutput( + output=(latents, audio_latents), + stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, + ) + + latents = latents.to(connector_prompt_embeds.dtype) + if not self.vae.config.timestep_conditioning: + timestep_decode = None + else: + noise = randn_tensor(latents.shape, generator=generator, device=device, dtype=latents.dtype) + timestep_decode, decode_noise_scale_t = _prepare_decode_timestep_conditioning( + decode_timestep=decode_timestep, + decode_noise_scale=decode_noise_scale, + prompt_batch_size=prompt_batch_size, + effective_batch_size=latents.shape[0], + device=device, + dtype=latents.dtype, + ) + latents = (1 - decode_noise_scale_t) * latents + decode_noise_scale_t * noise + + if _should_decode_video_on_rank(self.vae): + latents = latents.to(self.vae.dtype) + video = self.vae.decode(latents, timestep_decode, return_dict=False)[0] + else: + video = torch.empty(0, device=latents.device, dtype=latents.dtype) + + if not _is_output_rank(): + return DiffusionOutput( + output=( + torch.empty(0, device=video.device, dtype=video.dtype), + torch.empty(0, device=audio_latents.device, dtype=audio_latents.dtype), + ), + stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, + ) + + 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), + stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, + ) + # ------------------------------------------------------------------ # Latent preparation # ------------------------------------------------------------------ @@ -830,6 +1053,713 @@ class LTX23Pipeline( torch.cuda.current_stream(device).synchronize() return latents + def _resolve_request_inputs( + self, + req: DiffusionRequestBatch, + *, + prompt: str | list[str] | None, + negative_prompt: str | list[str] | None, + height: int | None, + width: int | None, + num_frames: int | None, + frame_rate: float | None, + num_inference_steps: int | None, + timesteps: list[int] | None, + guidance_scale: float, + num_videos_per_prompt: int | None, + generator: torch.Generator | list[torch.Generator] | None, + latents: torch.Tensor | None, + audio_latents: torch.Tensor | None, + prompt_embeds: torch.Tensor | None, + negative_prompt_embeds: torch.Tensor | None, + prompt_attention_mask: torch.Tensor | None, + negative_prompt_attention_mask: torch.Tensor | None, + decode_timestep: float | list[float], + decode_noise_scale: float | list[float] | None, + output_type: str, + max_sequence_length: int | None, + ) -> _LTX23RequestInputs: + sampling_params_list = req.sampling_params_list + common_sampling_params = sampling_params_list[0] + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt + if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): + negative_prompt = None + elif req.prompts: + negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] + + height = common_sampling_params.height or height or 512 + width = common_sampling_params.width or width or 768 + num_frames = common_sampling_params.num_frames or num_frames or 121 + frame_rate = common_sampling_params.resolved_frame_rate or frame_rate or 24.0 + num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps or 40 + if timesteps is None: + num_inference_steps = max(int(num_inference_steps), 2) + elif len(timesteps) < 2: + raise ValueError("`timesteps` must contain at least 2 values for FlowMatchEulerDiscreteScheduler.") + + num_videos_per_prompt = ( + common_sampling_params.num_outputs_per_prompt + if common_sampling_params.num_outputs_per_prompt > 0 + else num_videos_per_prompt or 1 + ) + max_sequence_length = ( + common_sampling_params.max_sequence_length or max_sequence_length or self.tokenizer_max_length + ) + + if common_sampling_params.guidance_scale_provided: + guidance_scale = common_sampling_params.guidance_scale + + if generator is None: + generator = req.collate_request_generators(num_videos_per_prompt, generator) + + latents = req.collate_request_tensors("latents", latents) + audio_latents = DiffusionRequestBatch.collate_tensors( + [_get_audio_latents_from_sampling(sampling) for sampling in sampling_params_list], + "audio_latents", + audio_latents, + ) + + prompt_fields = DiffusionRequestBatch.collate_prompt_field_map( + req.prompts, + { + "prompt_embeds": prompt_embeds, + "negative_prompt_embeds": negative_prompt_embeds, + "prompt_attention_mask": prompt_attention_mask, + "negative_prompt_attention_mask": negative_prompt_attention_mask, + }, + field_aliases={ + "prompt_attention_mask": ("prompt_attention_mask", "attention_mask"), + "negative_prompt_attention_mask": ( + "negative_prompt_attention_mask", + "negative_attention_mask", + ), + }, + ) + prompt_embeds = prompt_fields["prompt_embeds"] + negative_prompt_embeds = prompt_fields["negative_prompt_embeds"] + prompt_attention_mask = prompt_fields["prompt_attention_mask"] + negative_prompt_attention_mask = prompt_fields["negative_prompt_attention_mask"] + if prompt_embeds is not None: + prompt = None + if negative_prompt_embeds is not None: + negative_prompt = None + + if common_sampling_params.decode_timestep is not None: + decode_timestep = common_sampling_params.decode_timestep + if common_sampling_params.decode_noise_scale is not None: + decode_noise_scale = common_sampling_params.decode_noise_scale + if common_sampling_params.output_type is not None: + output_type = common_sampling_params.output_type + + return _LTX23RequestInputs( + prompt=prompt, + negative_prompt=negative_prompt, + height=int(height), + width=int(width), + num_frames=int(num_frames), + frame_rate=float(frame_rate), + num_inference_steps=int(num_inference_steps), + guidance_scale=guidance_scale, + num_videos_per_prompt=int(num_videos_per_prompt), + generator=generator, + latents=latents, + audio_latents=audio_latents, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + decode_timestep=decode_timestep, + decode_noise_scale=decode_noise_scale, + output_type=output_type, + max_sequence_length=int(max_sequence_length), + ) + + def _prepare_prompt_context( + self, + *, + prompt: str | list[str] | None, + negative_prompt: str | list[str] | None, + prompt_embeds: torch.Tensor | None, + negative_prompt_embeds: torch.Tensor | None, + prompt_attention_mask: torch.Tensor | None, + negative_prompt_attention_mask: torch.Tensor | None, + num_videos_per_prompt: int, + max_sequence_length: int, + ) -> _LTX23PromptContext: + if prompt is not None and isinstance(prompt, str): + batch_size = 1 + elif prompt is not None and isinstance(prompt, list): + batch_size = len(prompt) + else: + batch_size = prompt_embeds.shape[0] + + prompt_embeds, prompt_attention_mask, negative_prompt_embeds, negative_prompt_attention_mask = ( + self.encode_prompt( + prompt=prompt, + negative_prompt=negative_prompt, + do_classifier_free_guidance=self.do_classifier_free_guidance, + num_videos_per_prompt=num_videos_per_prompt, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + max_sequence_length=max_sequence_length, + device=self.device, + ) + ) + + if self.do_classifier_free_guidance: + 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) + + connector_prompt_embeds, connector_audio_prompt_embeds, connector_attention_mask = self.connectors( + prompt_embeds, + prompt_attention_mask, + padding_side=getattr(self.tokenizer, "padding_side", "left"), + ) + + positive_connector_prompt_embeds = connector_prompt_embeds + positive_connector_audio_prompt_embeds = connector_audio_prompt_embeds + positive_connector_attention_mask = connector_attention_mask + negative_connector_prompt_embeds = None + negative_connector_audio_prompt_embeds = None + negative_connector_attention_mask = None + if self.do_classifier_free_guidance: + split_batch = batch_size * num_videos_per_prompt + negative_connector_prompt_embeds = connector_prompt_embeds[:split_batch] + positive_connector_prompt_embeds = connector_prompt_embeds[split_batch:] + negative_connector_audio_prompt_embeds = connector_audio_prompt_embeds[:split_batch] + positive_connector_audio_prompt_embeds = connector_audio_prompt_embeds[split_batch:] + negative_connector_attention_mask = connector_attention_mask[:split_batch] + positive_connector_attention_mask = connector_attention_mask[split_batch:] + + return _LTX23PromptContext( + batch_size=batch_size, + connector_prompt_embeds=connector_prompt_embeds, + connector_audio_prompt_embeds=connector_audio_prompt_embeds, + connector_attention_mask=connector_attention_mask, + positive_connector_prompt_embeds=positive_connector_prompt_embeds, + positive_connector_audio_prompt_embeds=positive_connector_audio_prompt_embeds, + positive_connector_attention_mask=positive_connector_attention_mask, + negative_connector_prompt_embeds=negative_connector_prompt_embeds, + negative_connector_audio_prompt_embeds=negative_connector_audio_prompt_embeds, + negative_connector_attention_mask=negative_connector_attention_mask, + ) + + def _setup_forward_runtime( + self, + request_inputs: _LTX23RequestInputs, + attention_kwargs: dict[str, Any] | None, + ) -> bool: + self._guidance_scale = request_inputs.guidance_scale + self._attention_kwargs = attention_kwargs + self._interrupt = False + self._current_timestep = None + cfg_world_size = get_classifier_free_guidance_world_size() + if self.do_classifier_free_guidance and cfg_world_size not in (1, 2): + raise ValueError( + f"LTX23Pipeline supports CFG parallelism with cfg_parallel_size 1 or 2, but got {cfg_world_size}." + ) + return self.do_classifier_free_guidance and cfg_world_size > 1 + + def _check_forward_inputs( + self, + request_inputs: _LTX23RequestInputs, + image: Any | None = None, + ) -> None: + self.check_inputs( + prompt=request_inputs.prompt, + height=request_inputs.height, + width=request_inputs.width, + prompt_embeds=request_inputs.prompt_embeds, + negative_prompt_embeds=request_inputs.negative_prompt_embeds, + prompt_attention_mask=request_inputs.prompt_attention_mask, + negative_prompt_attention_mask=request_inputs.negative_prompt_attention_mask, + ) + + def _resolve_video_latent_dimensions( + self, + request_inputs: _LTX23RequestInputs, + ) -> tuple[int, int, int]: + latent_num_frames = (request_inputs.num_frames - 1) // self.vae_temporal_compression_ratio + 1 + latent_height = request_inputs.height // self.vae_spatial_compression_ratio + latent_width = request_inputs.width // self.vae_spatial_compression_ratio + if request_inputs.latents is not None and request_inputs.latents.ndim == 5: + _, _, latent_num_frames, latent_height, latent_width = request_inputs.latents.shape + return latent_num_frames, latent_height, latent_width + + def _prepare_video_latents_stage( + self, + request_inputs: _LTX23RequestInputs, + prompt_context: _LTX23PromptContext, + *, + device: torch.device, + noise_scale: float, + image: Any | None = None, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + num_channels_latents = self.transformer.config.in_channels + latents = self.prepare_latents( + prompt_context.batch_size * request_inputs.num_videos_per_prompt, + num_channels_latents, + request_inputs.height, + request_inputs.width, + request_inputs.num_frames, + noise_scale, + torch.float32, + device, + request_inputs.generator, + request_inputs.latents, + ) + return latents, None + + def _prepare_audio_latents_stage( + self, + request_inputs: _LTX23RequestInputs, + prompt_context: _LTX23PromptContext, + *, + device: torch.device, + noise_scale: float, + ) -> tuple[torch.Tensor, int, int, int]: + duration_s = request_inputs.num_frames / request_inputs.frame_rate + audio_latents_per_second = ( + self.audio_sampling_rate / self.audio_hop_length / float(self.audio_vae_temporal_compression_ratio) + ) + audio_num_frames = round(duration_s * audio_latents_per_second) + audio_num_frames = self._resolve_audio_latent_length(audio_num_frames, request_inputs.audio_latents) + + num_mel_bins = self.audio_vae.config.mel_bins if self.audio_vae is not None else 64 + latent_mel_bins = num_mel_bins // self.audio_vae_mel_compression_ratio + num_channels_latents_audio = self.audio_vae.config.latent_channels if self.audio_vae is not None else 8 + audio_latents, original_audio_num_frames, padded_audio_num_frames = self.prepare_audio_latents( + prompt_context.batch_size * request_inputs.num_videos_per_prompt, + num_channels_latents=num_channels_latents_audio, + audio_latent_length=audio_num_frames, + num_mel_bins=num_mel_bins, + noise_scale=noise_scale, + dtype=torch.float32, + device=device, + generator=request_inputs.generator, + latents=request_inputs.audio_latents, + ) + return audio_latents, original_audio_num_frames, padded_audio_num_frames, latent_mel_bins + + def _make_video_audio_scheduler( + self, + audio_scheduler: Any, + latent_num_frames: int, + latent_height: int, + latent_width: int, + ) -> Any: + return _VideoAudioScheduler(self.scheduler, audio_scheduler) + + def _prepare_scheduler_stage( + self, + request_inputs: _LTX23RequestInputs, + *, + device: torch.device, + sigmas: list[float] | None, + timesteps: list[int] | None, + latent_num_frames: int, + latent_height: int, + latent_width: int, + ) -> tuple[Any, Any, torch.Tensor]: + sigmas = ( + np.linspace(1.0, 1 / request_inputs.num_inference_steps, request_inputs.num_inference_steps) + if sigmas is None + else sigmas + ) + # Use max_image_seq_len (not actual video_sequence_length) for mu calculation, + # matching diffusers' LTX2Pipeline which hardcodes this value. + mu = calculate_shift( + self.scheduler.config.get("max_image_seq_len", 4096), + self.scheduler.config.get("base_image_seq_len", 1024), + self.scheduler.config.get("max_image_seq_len", 4096), + self.scheduler.config.get("base_shift", 0.95), + self.scheduler.config.get("max_shift", 2.05), + ) + audio_scheduler = copy.deepcopy(self.scheduler) + video_audio_scheduler = self._make_video_audio_scheduler( + audio_scheduler, + latent_num_frames, + latent_height, + latent_width, + ) + _ = retrieve_timesteps( + audio_scheduler, + request_inputs.num_inference_steps, + device, + timesteps, + sigmas=sigmas, + mu=mu, + ) + timesteps, _ = retrieve_timesteps( + self.scheduler, + request_inputs.num_inference_steps, + device, + timesteps, + sigmas=sigmas, + mu=mu, + ) + self._num_timesteps = len(timesteps) + return audio_scheduler, video_audio_scheduler, timesteps + + def _prepare_rope_coords_stage( + self, + forward_ctx: _LTX23ForwardContext, + latents: torch.Tensor, + audio_latents: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + request_inputs = forward_ctx.request_inputs + video_coords = self.transformer.rope.prepare_video_coords( + latents.shape[0], + forward_ctx.latent_num_frames, + forward_ctx.latent_height, + forward_ctx.latent_width, + latents.device, + fps=request_inputs.frame_rate, + ) + audio_coords = self.transformer.audio_rope.prepare_audio_coords( + audio_latents.shape[0], + forward_ctx.padded_audio_num_frames, + audio_latents.device, + ) + return video_coords, audio_coords + + def _prepare_denoise_context_for_cfg( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + ) -> _LTX23DenoiseContext: + if self.do_classifier_free_guidance and not forward_ctx.cfg_parallel_ready: + denoise_ctx.video_coords = denoise_ctx.video_coords.repeat( + (2,) + (1,) * (denoise_ctx.video_coords.ndim - 1) + ) + denoise_ctx.audio_coords = denoise_ctx.audio_coords.repeat( + (2,) + (1,) * (denoise_ctx.audio_coords.ndim - 1) + ) + return denoise_ctx + + def _denoise_timestep_kwargs( + self, + ts: torch.Tensor, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + ) -> dict[str, torch.Tensor]: + return {"timestep": ts, "sigma": ts} + + def _build_transformer_kwargs( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + *, + hidden_states: torch.Tensor, + audio_hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor, + audio_encoder_hidden_states: torch.Tensor, + encoder_attention_mask: torch.Tensor, + audio_encoder_attention_mask: torch.Tensor, + ts: torch.Tensor, + ) -> dict[str, Any]: + request_inputs = forward_ctx.request_inputs + return { + "hidden_states": hidden_states, + "audio_hidden_states": audio_hidden_states, + "encoder_hidden_states": encoder_hidden_states, + "audio_encoder_hidden_states": audio_encoder_hidden_states, + **self._denoise_timestep_kwargs(ts, forward_ctx, denoise_ctx), + "encoder_attention_mask": encoder_attention_mask, + "audio_encoder_attention_mask": audio_encoder_attention_mask, + "num_frames": forward_ctx.latent_num_frames, + "height": forward_ctx.latent_height, + "width": forward_ctx.latent_width, + "fps": request_inputs.frame_rate, + "audio_num_frames": forward_ctx.padded_audio_num_frames, + "video_coords": denoise_ctx.video_coords, + "audio_coords": denoise_ctx.audio_coords, + "attention_kwargs": forward_ctx.attention_kwargs, + "return_dict": False, + } + + def _step_denoised_latents( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + noise_pred_video: torch.Tensor, + noise_pred_audio: torch.Tensor, + t: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + if forward_ctx.cfg_parallel_ready: + latents, audio_latents = self.scheduler_step_maybe_with_cfg( + (noise_pred_video, noise_pred_audio), + (t, t), + (denoise_ctx.latents, denoise_ctx.audio_latents), + do_true_cfg=self.do_classifier_free_guidance, + per_request_scheduler=forward_ctx.video_audio_scheduler, + ) + return self._synchronize_cfg_parallel_step_output( + (latents, audio_latents), + do_true_cfg=self.do_classifier_free_guidance, + ) + + latents = self.scheduler.step(noise_pred_video, t, denoise_ctx.latents, return_dict=False)[0] + audio_latents = forward_ctx.audio_scheduler.step( + noise_pred_audio, + t, + denoise_ctx.audio_latents, + return_dict=False, + )[0] + return latents, audio_latents + + def _denoise_loop( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + ) -> tuple[torch.Tensor, torch.Tensor]: + request_inputs = forward_ctx.request_inputs + prompt_context = forward_ctx.prompt_context + guidance_scale = request_inputs.guidance_scale + audio_scheduler = forward_ctx.audio_scheduler + + with self.progress_bar(total=len(forward_ctx.timesteps)) as pbar: + for i, t in enumerate(forward_ctx.timesteps): + if self.interrupt: + continue + + self._current_timestep = t + + if forward_ctx.cfg_parallel_ready: + latent_model_input = denoise_ctx.latents.to(prompt_context.positive_connector_prompt_embeds.dtype) + audio_latent_model_input = denoise_ctx.audio_latents.to( + prompt_context.positive_connector_prompt_embeds.dtype + ) + ts = t.expand(latent_model_input.shape[0]) + positive_kwargs = self._build_transformer_kwargs( + forward_ctx, + denoise_ctx, + hidden_states=latent_model_input, + audio_hidden_states=audio_latent_model_input, + encoder_hidden_states=prompt_context.positive_connector_prompt_embeds, + audio_encoder_hidden_states=prompt_context.positive_connector_audio_prompt_embeds, + encoder_attention_mask=prompt_context.positive_connector_attention_mask, + audio_encoder_attention_mask=prompt_context.positive_connector_attention_mask, + ts=ts, + ) + negative_kwargs = { + **positive_kwargs, + "encoder_hidden_states": prompt_context.negative_connector_prompt_embeds, + "audio_encoder_hidden_states": prompt_context.negative_connector_audio_prompt_embeds, + "encoder_attention_mask": prompt_context.negative_connector_attention_mask, + "audio_encoder_attention_mask": prompt_context.negative_connector_attention_mask, + } + noise_pred_video, noise_pred_audio = self.predict_noise_with_parallel_cfg( + true_cfg_scale=guidance_scale, + positive_kwargs=positive_kwargs, + negative_kwargs=negative_kwargs, + cfg_normalize=False, + video_latents=denoise_ctx.latents, + audio_latents=denoise_ctx.audio_latents, + video_sigma=self.scheduler.sigmas[i], + audio_sigma=audio_scheduler.sigmas[i], + ) + else: + latent_model_input = ( + torch.cat([denoise_ctx.latents] * 2) + if self.do_classifier_free_guidance + else denoise_ctx.latents + ) + latent_model_input = latent_model_input.to(prompt_context.connector_prompt_embeds.dtype) + audio_latent_model_input = ( + torch.cat([denoise_ctx.audio_latents] * 2) + if self.do_classifier_free_guidance + else denoise_ctx.audio_latents + ) + audio_latent_model_input = audio_latent_model_input.to(prompt_context.connector_prompt_embeds.dtype) + ts = t.expand(latent_model_input.shape[0]) + + transformer_kwargs = self._build_transformer_kwargs( + forward_ctx, + denoise_ctx, + hidden_states=latent_model_input, + audio_hidden_states=audio_latent_model_input, + encoder_hidden_states=prompt_context.connector_prompt_embeds, + audio_encoder_hidden_states=prompt_context.connector_audio_prompt_embeds, + encoder_attention_mask=prompt_context.connector_attention_mask, + audio_encoder_attention_mask=prompt_context.connector_attention_mask, + ts=ts, + ) + with self._transformer_cache_context("cond_uncond"): + noise_pred_video, noise_pred_audio = self.transformer(**transformer_kwargs) + + noise_pred_video = noise_pred_video.float() + noise_pred_audio = noise_pred_audio.float() + + if self.do_classifier_free_guidance: + noise_pred_video_uncond, noise_pred_video_cond = noise_pred_video.chunk(2) + noise_pred_video = self._combine_x0_space_cfg( + denoise_ctx.latents, + noise_pred_video_cond, + noise_pred_video_uncond, + self.scheduler.sigmas[i], + guidance_scale, + ) + + noise_pred_audio_uncond, noise_pred_audio_cond = noise_pred_audio.chunk(2) + noise_pred_audio = self._combine_x0_space_cfg( + denoise_ctx.audio_latents, + noise_pred_audio_cond, + noise_pred_audio_uncond, + audio_scheduler.sigmas[i], + guidance_scale, + ) + + denoise_ctx.latents, denoise_ctx.audio_latents = self._step_denoised_latents( + forward_ctx, + denoise_ctx, + noise_pred_video, + noise_pred_audio, + t, + ) + pbar.update() + + return denoise_ctx.latents, denoise_ctx.audio_latents + + def _unpack_and_denormalize_stage( + self, + forward_ctx: _LTX23ForwardContext, + latents: torch.Tensor, + audio_latents: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + latents = self._unpack_latents( + latents, + forward_ctx.latent_num_frames, + forward_ctx.latent_height, + forward_ctx.latent_width, + self.transformer_spatial_patch_size, + self.transformer_temporal_patch_size, + ) + latents = self._denormalize_latents( + latents, + self.vae.latents_mean, + self.vae.latents_std, + self.vae.config.scaling_factor, + ) + + audio_latents = self._unpad_audio_latents(audio_latents, forward_ctx.original_audio_num_frames) + audio_latents = self._denormalize_audio_latents( + audio_latents, + self.audio_vae.latents_mean, + self.audio_vae.latents_std, + ) + audio_latents = self._unpack_audio_latents( + audio_latents, + forward_ctx.original_audio_num_frames, + num_mel_bins=forward_ctx.latent_mel_bins, + ) + return latents, audio_latents + + def _decode_and_split( + self, + forward_ctx: _LTX23ForwardContext, + latents: torch.Tensor, + audio_latents: torch.Tensor, + ) -> list[DiffusionOutput]: + request_inputs = forward_ctx.request_inputs + return split_diffusion_output_by_request( + self._decode_output( + latents=latents, + audio_latents=audio_latents, + output_type=request_inputs.output_type, + connector_prompt_embeds=forward_ctx.prompt_context.connector_prompt_embeds, + generator=request_inputs.generator, + device=forward_ctx.device, + decode_timestep=request_inputs.decode_timestep, + decode_noise_scale=request_inputs.decode_noise_scale, + prompt_batch_size=forward_ctx.batch_size, + ), + forward_ctx.req, + num_outputs_per_prompt=forward_ctx.num_videos_per_prompt, + ) + + def _forward_impl( + self, + req: DiffusionRequestBatch, + request_inputs: _LTX23RequestInputs, + *, + noise_scale: float, + sigmas: list[float] | None, + timesteps: list[int] | None, + attention_kwargs: dict[str, Any] | None, + image: Any | None = None, + ) -> list[DiffusionOutput]: + self._check_forward_inputs(request_inputs, image=image) + cfg_parallel_ready = self._setup_forward_runtime(request_inputs, attention_kwargs) + device = self.device + prompt_context = self._prepare_prompt_context( + prompt=request_inputs.prompt, + negative_prompt=request_inputs.negative_prompt, + prompt_embeds=request_inputs.prompt_embeds, + negative_prompt_embeds=request_inputs.negative_prompt_embeds, + prompt_attention_mask=request_inputs.prompt_attention_mask, + negative_prompt_attention_mask=request_inputs.negative_prompt_attention_mask, + num_videos_per_prompt=request_inputs.num_videos_per_prompt, + max_sequence_length=request_inputs.max_sequence_length, + ) + + latent_num_frames, latent_height, latent_width = self._resolve_video_latent_dimensions(request_inputs) + latents, conditioning_mask = self._prepare_video_latents_stage( + request_inputs, + prompt_context, + device=device, + noise_scale=noise_scale, + image=image, + ) + audio_latents, original_audio_num_frames, padded_audio_num_frames, latent_mel_bins = ( + self._prepare_audio_latents_stage( + request_inputs, + prompt_context, + device=device, + noise_scale=noise_scale, + ) + ) + audio_scheduler, video_audio_scheduler, timesteps_tensor = self._prepare_scheduler_stage( + request_inputs, + device=device, + sigmas=sigmas, + timesteps=timesteps, + latent_num_frames=latent_num_frames, + latent_height=latent_height, + latent_width=latent_width, + ) + forward_ctx = _LTX23ForwardContext( + req=req, + request_inputs=request_inputs, + prompt_context=prompt_context, + device=device, + cfg_parallel_ready=cfg_parallel_ready, + attention_kwargs=attention_kwargs, + latent_num_frames=latent_num_frames, + latent_height=latent_height, + latent_width=latent_width, + latent_mel_bins=latent_mel_bins, + original_audio_num_frames=original_audio_num_frames, + padded_audio_num_frames=padded_audio_num_frames, + timesteps=timesteps_tensor, + audio_scheduler=audio_scheduler, + video_audio_scheduler=video_audio_scheduler, + ) + video_coords, audio_coords = self._prepare_rope_coords_stage(forward_ctx, latents, audio_latents) + denoise_ctx = _LTX23DenoiseContext( + latents=latents, + audio_latents=audio_latents, + video_coords=video_coords, + audio_coords=audio_coords, + conditioning_mask=conditioning_mask, + ) + denoise_ctx = self._prepare_denoise_context_for_cfg(forward_ctx, denoise_ctx) + latents, audio_latents = self._denoise_loop(forward_ctx, denoise_ctx) + latents, audio_latents = self._unpack_and_denormalize_stage(forward_ctx, latents, audio_latents) + return self._decode_and_split(forward_ctx, latents, audio_latents) + # ------------------------------------------------------------------ # Forward pass # ------------------------------------------------------------------ @@ -864,446 +1794,39 @@ class LTX23Pipeline( attention_kwargs: dict[str, Any] | None = None, max_sequence_length: int | None = None, ) -> list[DiffusionOutput]: - # ---- Extract from request ---- - sampling_params_list = req.sampling_params_list - common_sampling_params = sampling_params_list[0] - prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in req.prompts] or prompt - if all(isinstance(p, str) or p.get("negative_prompt") is None for p in req.prompts): - negative_prompt = None - elif req.prompts: - negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in req.prompts] - - height = common_sampling_params.height or height or 512 - width = common_sampling_params.width or width or 768 - num_frames = common_sampling_params.num_frames or num_frames or 121 - frame_rate = common_sampling_params.resolved_frame_rate or frame_rate or 24.0 - num_inference_steps = common_sampling_params.num_inference_steps or num_inference_steps or 40 - # Enforce minimum of 2 timesteps for flow matching scheduler - if timesteps is None: - num_inference_steps = max(int(num_inference_steps), 2) - elif len(timesteps) < 2: - raise ValueError("`timesteps` must contain at least 2 values for FlowMatchEulerDiscreteScheduler.") - num_videos_per_prompt = ( - common_sampling_params.num_outputs_per_prompt - if common_sampling_params.num_outputs_per_prompt > 0 - else num_videos_per_prompt or 1 - ) - max_sequence_length = ( - common_sampling_params.max_sequence_length or max_sequence_length or self.tokenizer_max_length - ) - - if common_sampling_params.guidance_scale_provided: - guidance_scale = common_sampling_params.guidance_scale - - if generator is None: - generator = req.collate_request_generators(num_videos_per_prompt, generator) - - latents = req.collate_request_tensors("latents", latents) - audio_latents = DiffusionRequestBatch.collate_tensors( - [_get_audio_latents_from_sampling(sampling) for sampling in sampling_params_list], - "audio_latents", - audio_latents, - ) - - # Override with pre-computed embeddings if provided in request - prompt_fields = DiffusionRequestBatch.collate_prompt_field_map( - req.prompts, - { - "prompt_embeds": prompt_embeds, - "negative_prompt_embeds": negative_prompt_embeds, - "prompt_attention_mask": prompt_attention_mask, - "negative_prompt_attention_mask": negative_prompt_attention_mask, - }, - field_aliases={ - "prompt_attention_mask": ("prompt_attention_mask", "attention_mask"), - "negative_prompt_attention_mask": ( - "negative_prompt_attention_mask", - "negative_attention_mask", - ), - }, - ) - prompt_embeds = prompt_fields["prompt_embeds"] - negative_prompt_embeds = prompt_fields["negative_prompt_embeds"] - prompt_attention_mask = prompt_fields["prompt_attention_mask"] - negative_prompt_attention_mask = prompt_fields["negative_prompt_attention_mask"] - if prompt_embeds is not None: - prompt = None - if negative_prompt_embeds is not None: - negative_prompt = None - - if common_sampling_params.decode_timestep is not None: - decode_timestep = common_sampling_params.decode_timestep - if common_sampling_params.decode_noise_scale is not None: - decode_noise_scale = common_sampling_params.decode_noise_scale - if common_sampling_params.output_type is not None: - output_type = common_sampling_params.output_type - - self.check_inputs( - prompt=prompt, - height=height, - width=width, - prompt_embeds=prompt_embeds, - negative_prompt_embeds=negative_prompt_embeds, - prompt_attention_mask=prompt_attention_mask, - negative_prompt_attention_mask=negative_prompt_attention_mask, - ) - - self._guidance_scale = guidance_scale - self._attention_kwargs = attention_kwargs - self._interrupt = False - self._current_timestep = None - cfg_world_size = get_classifier_free_guidance_world_size() - if self.do_classifier_free_guidance and cfg_world_size not in (1, 2): - raise ValueError( - f"LTX23Pipeline supports CFG parallelism with cfg_parallel_size 1 or 2, but got {cfg_world_size}." - ) - cfg_parallel_ready = self.do_classifier_free_guidance and cfg_world_size > 1 - - if prompt is not None and isinstance(prompt, str): - batch_size = 1 - elif prompt is not None and isinstance(prompt, list): - batch_size = len(prompt) - else: - batch_size = prompt_embeds.shape[0] - - device = self.device - - # ---- Encode prompts ---- - ( - prompt_embeds, - prompt_attention_mask, - negative_prompt_embeds, - negative_prompt_attention_mask, - ) = self.encode_prompt( + request_inputs = self._resolve_request_inputs( + req, prompt=prompt, negative_prompt=negative_prompt, - do_classifier_free_guidance=self.do_classifier_free_guidance, + height=height, + width=width, + num_frames=num_frames, + frame_rate=frame_rate, + num_inference_steps=num_inference_steps, + timesteps=timesteps, + guidance_scale=guidance_scale, num_videos_per_prompt=num_videos_per_prompt, + generator=generator, + latents=latents, + audio_latents=audio_latents, prompt_embeds=prompt_embeds, negative_prompt_embeds=negative_prompt_embeds, prompt_attention_mask=prompt_attention_mask, negative_prompt_attention_mask=negative_prompt_attention_mask, + decode_timestep=decode_timestep, + decode_noise_scale=decode_noise_scale, + output_type=output_type, max_sequence_length=max_sequence_length, - device=device, ) - - # ---- Connectors (LTX-2.3: padding_side API) ---- - # Concatenate negative + positive embeddings BEFORE connector call, - # matching diffusers which calls connectors once with batch=2. - # This ensures batch-dependent operations produce identical results. - if self.do_classifier_free_guidance: - 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) - - 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 - ) - - positive_connector_prompt_embeds = connector_prompt_embeds - positive_connector_audio_prompt_embeds = connector_audio_prompt_embeds - positive_connector_attention_mask = connector_attention_mask - negative_connector_prompt_embeds = None - negative_connector_audio_prompt_embeds = None - negative_connector_attention_mask = None - if self.do_classifier_free_guidance: - split_batch = batch_size * num_videos_per_prompt - negative_connector_prompt_embeds = connector_prompt_embeds[:split_batch] - positive_connector_prompt_embeds = connector_prompt_embeds[split_batch:] - negative_connector_audio_prompt_embeds = connector_audio_prompt_embeds[:split_batch] - positive_connector_audio_prompt_embeds = connector_audio_prompt_embeds[split_batch:] - negative_connector_attention_mask = connector_attention_mask[:split_batch] - positive_connector_attention_mask = connector_attention_mask[split_batch:] - - # ---- Prepare latents ---- - latent_num_frames = (num_frames - 1) // self.vae_temporal_compression_ratio + 1 - latent_height = height // self.vae_spatial_compression_ratio - latent_width = width // self.vae_spatial_compression_ratio - if latents is not None and latents.ndim == 5: - _, _, latent_num_frames, latent_height, latent_width = latents.shape - - num_channels_latents = self.transformer.config.in_channels - latents = self.prepare_latents( - batch_size * num_videos_per_prompt, - num_channels_latents, - height, - width, - num_frames, - noise_scale, - torch.float32, - device, - generator, - latents, - ) - - duration_s = num_frames / frame_rate - audio_latents_per_second = ( - self.audio_sampling_rate / self.audio_hop_length / float(self.audio_vae_temporal_compression_ratio) - ) - audio_num_frames = round(duration_s * audio_latents_per_second) - audio_num_frames = self._resolve_audio_latent_length(audio_num_frames, audio_latents) - - num_mel_bins = self.audio_vae.config.mel_bins if self.audio_vae is not None else 64 - latent_mel_bins = num_mel_bins // self.audio_vae_mel_compression_ratio - num_channels_latents_audio = self.audio_vae.config.latent_channels if self.audio_vae is not None else 8 - audio_latents, original_audio_num_frames, padded_audio_num_frames = self.prepare_audio_latents( - batch_size * num_videos_per_prompt, - num_channels_latents=num_channels_latents_audio, - audio_latent_length=audio_num_frames, - num_mel_bins=num_mel_bins, - noise_scale=noise_scale, - dtype=torch.float32, - device=device, - generator=generator, - latents=audio_latents, - ) - - # ---- Scheduler setup ---- - sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps) if sigmas is None else sigmas - # Use max_image_seq_len (not actual video_sequence_length) for mu calculation, - # matching diffusers' LTX2Pipeline which hardcodes this value. - mu = calculate_shift( - self.scheduler.config.get("max_image_seq_len", 4096), - self.scheduler.config.get("base_image_seq_len", 1024), - self.scheduler.config.get("max_image_seq_len", 4096), - self.scheduler.config.get("base_shift", 0.95), - self.scheduler.config.get("max_shift", 2.05), - ) - audio_scheduler = copy.deepcopy(self.scheduler) - video_audio_scheduler = _VideoAudioScheduler(self.scheduler, audio_scheduler) - _ = retrieve_timesteps(audio_scheduler, num_inference_steps, device, timesteps, sigmas=sigmas, mu=mu) - timesteps, num_inference_steps = retrieve_timesteps( - self.scheduler, - num_inference_steps, - device, - timesteps, - sigmas=sigmas, - mu=mu, - ) - self._num_timesteps = len(timesteps) - - # ---- RoPE coordinates ---- - video_coords = self.transformer.rope.prepare_video_coords( - latents.shape[0], - latent_num_frames, - latent_height, - latent_width, - latents.device, - fps=frame_rate, - ) - audio_coords = self.transformer.audio_rope.prepare_audio_coords( - audio_latents.shape[0], - padded_audio_num_frames, - audio_latents.device, - ) - - # ---- CFG: duplicate coords for single-rank batch=2 CFG ---- - # Connector outputs are already batch=2 (neg+pos concatenated before connector call) - if self.do_classifier_free_guidance and not cfg_parallel_ready: - video_coords = video_coords.repeat((2,) + (1,) * (video_coords.ndim - 1)) - audio_coords = audio_coords.repeat((2,) + (1,) * (audio_coords.ndim - 1)) - - # ---- Denoising loop ---- - # Uses x0-space CFG (delta formulation) matching diffusers' LTX2Pipeline. - # The velocity predictions are converted to x0, guidance is applied in x0 - # space, then converted back to velocity for the scheduler step. - with self.progress_bar(total=len(timesteps)) as pbar: - for i, t in enumerate(timesteps): - if self.interrupt: - continue - - self._current_timestep = t - - if cfg_parallel_ready: - latent_model_input = latents.to(positive_connector_prompt_embeds.dtype) - audio_latent_model_input = audio_latents.to(positive_connector_prompt_embeds.dtype) - ts = t.expand(latent_model_input.shape[0]) - positive_kwargs = { - "hidden_states": latent_model_input, - "audio_hidden_states": audio_latent_model_input, - "encoder_hidden_states": positive_connector_prompt_embeds, - "audio_encoder_hidden_states": positive_connector_audio_prompt_embeds, - "timestep": ts, - "sigma": ts, - "encoder_attention_mask": positive_connector_attention_mask, - "audio_encoder_attention_mask": positive_connector_attention_mask, - "num_frames": latent_num_frames, - "height": latent_height, - "width": latent_width, - "fps": frame_rate, - "audio_num_frames": padded_audio_num_frames, - "video_coords": video_coords, - "audio_coords": audio_coords, - "attention_kwargs": attention_kwargs, - "return_dict": False, - } - negative_kwargs = { - **positive_kwargs, - "encoder_hidden_states": negative_connector_prompt_embeds, - "audio_encoder_hidden_states": negative_connector_audio_prompt_embeds, - "encoder_attention_mask": negative_connector_attention_mask, - "audio_encoder_attention_mask": negative_connector_attention_mask, - } - noise_pred_video, noise_pred_audio = self.predict_noise_with_parallel_cfg( - true_cfg_scale=guidance_scale, - positive_kwargs=positive_kwargs, - negative_kwargs=negative_kwargs, - cfg_normalize=False, - video_latents=latents, - audio_latents=audio_latents, - video_sigma=self.scheduler.sigmas[i], - audio_sigma=audio_scheduler.sigmas[i], - ) - - latents, audio_latents = self.scheduler_step_maybe_with_cfg( - (noise_pred_video, noise_pred_audio), - (t, t), - (latents, audio_latents), - do_true_cfg=self.do_classifier_free_guidance, - per_request_scheduler=video_audio_scheduler, - ) - latents, audio_latents = self._synchronize_cfg_parallel_step_output( - (latents, audio_latents), - do_true_cfg=self.do_classifier_free_guidance, - ) - else: - latent_model_input = torch.cat([latents] * 2) if self.do_classifier_free_guidance else latents - latent_model_input = latent_model_input.to(connector_prompt_embeds.dtype) - audio_latent_model_input = ( - torch.cat([audio_latents] * 2) if self.do_classifier_free_guidance else audio_latents - ) - audio_latent_model_input = audio_latent_model_input.to(connector_prompt_embeds.dtype) - ts = t.expand(latent_model_input.shape[0]) - - with self._transformer_cache_context("cond_uncond"): - noise_pred_video, noise_pred_audio = self.transformer( - hidden_states=latent_model_input, - audio_hidden_states=audio_latent_model_input, - encoder_hidden_states=connector_prompt_embeds, - audio_encoder_hidden_states=connector_audio_prompt_embeds, - timestep=ts, - sigma=ts, # LTX-2.3: sigma for prompt_adaln - encoder_attention_mask=connector_attention_mask, - audio_encoder_attention_mask=connector_attention_mask, - num_frames=latent_num_frames, - height=latent_height, - width=latent_width, - fps=frame_rate, - audio_num_frames=padded_audio_num_frames, - video_coords=video_coords, - audio_coords=audio_coords, - attention_kwargs=attention_kwargs, - return_dict=False, - ) - - noise_pred_video = noise_pred_video.float() - noise_pred_audio = noise_pred_audio.float() - - if self.do_classifier_free_guidance: - noise_pred_video_uncond, noise_pred_video_cond = noise_pred_video.chunk(2) - noise_pred_video = self._combine_x0_space_cfg( - latents, - noise_pred_video_cond, - noise_pred_video_uncond, - self.scheduler.sigmas[i], - guidance_scale, - ) - - noise_pred_audio_uncond, noise_pred_audio_cond = noise_pred_audio.chunk(2) - noise_pred_audio = self._combine_x0_space_cfg( - audio_latents, - noise_pred_audio_cond, - noise_pred_audio_uncond, - audio_scheduler.sigmas[i], - guidance_scale, - ) - - latents = self.scheduler.step(noise_pred_video, t, latents, return_dict=False)[0] - audio_latents = audio_scheduler.step(noise_pred_audio, t, audio_latents, return_dict=False)[0] - - pbar.update() - - # ---- Unpack and denormalize ---- - latents = self._unpack_latents( - latents, - latent_num_frames, - latent_height, - latent_width, - self.transformer_spatial_patch_size, - self.transformer_temporal_patch_size, - ) - latents = self._denormalize_latents( - latents, - self.vae.latents_mean, - self.vae.latents_std, - self.vae.config.scaling_factor, - ) - - audio_latents = self._unpad_audio_latents(audio_latents, original_audio_num_frames) - audio_latents = self._denormalize_audio_latents( - audio_latents, - self.audio_vae.latents_mean, - self.audio_vae.latents_std, - ) - audio_latents = self._unpack_audio_latents( - audio_latents, - original_audio_num_frames, - num_mel_bins=latent_mel_bins, - ) - - # ---- Decode ---- - if output_type == "latent": - video = latents - audio = audio_latents - else: - latents = latents.to(connector_prompt_embeds.dtype) - - if not self.vae.config.timestep_conditioning: - timestep_decode = None - else: - noise = randn_tensor(latents.shape, generator=generator, device=device, dtype=latents.dtype) - if not isinstance(decode_timestep, list): - decode_timestep = [decode_timestep] * batch_size - if decode_noise_scale is None: - decode_noise_scale = decode_timestep - elif not isinstance(decode_noise_scale, list): - decode_noise_scale = [decode_noise_scale] * batch_size - timestep_decode = torch.tensor(decode_timestep, device=device, dtype=latents.dtype) - decode_noise_scale_t = torch.tensor(decode_noise_scale, device=device, dtype=latents.dtype)[ - :, None, None, None, None - ] - latents = (1 - decode_noise_scale_t) * latents + decode_noise_scale_t * noise - - latents = latents.to(self.vae.dtype) - video = self.vae.decode(latents, timestep_decode, return_dict=False)[0] - 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 split_diffusion_output_by_request( - DiffusionOutput( - output=(video, audio), - stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, - ), + return self._forward_impl( req, - num_outputs_per_prompt=num_videos_per_prompt, + request_inputs, + noise_scale=noise_scale, + sigmas=sigmas, + timesteps=timesteps, + attention_kwargs=attention_kwargs, ) def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: loader = AutoWeightsLoader(self) return loader.load_weights(weights) - - -class LTX23ImageToVideoPipeline(nn.Module): - """LTX-2.3 image-to-video pipeline placeholder.""" - - def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): - super().__init__() - raise NotImplementedError( - "LTX23ImageToVideoPipeline is not yet implemented. " - "Use LTX23Pipeline for single-stage text-to-video generation." - ) diff --git a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3_image2video.py b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3_image2video.py index d30ef5464..bcccd7596 100644 --- a/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3_image2video.py +++ b/vllm_omni/diffusion/models/ltx2/pipeline_ltx2_3_image2video.py @@ -1,16 +1,452 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Re-exports for LTX-2.3 I2V pipeline variants. +"""LTX-2.3 image-to-video pipeline.""" -The registry loads pipeline classes by (mod_folder, mod_relname, cls_name). -This module exposes the I2V class names so the registry can find them. -""" +from __future__ import annotations + +from typing import Any + +import PIL.Image +import torch +from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_img2img import retrieve_latents +from diffusers.utils.torch_utils import randn_tensor +from diffusers.video_processor import VideoProcessor + +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from .pipeline_ltx2_3 import ( - LTX23ImageToVideoPipeline, - get_ltx2_post_process_func, # noqa: F401 - loaded by registry via getattr + LTX23Pipeline, + _LTX23DenoiseContext, + _LTX23ForwardContext, + _LTX23PromptContext, + _LTX23RequestInputs, + get_ltx2_post_process_func, ) +from .pipeline_ltx2_image2video import LTX2ImageToVideoPipeline, _I2VVideoAudioScheduler + + +class LTX23ImageToVideoPipeline(LTX23Pipeline): + """LTX-2.3 image-to-video pipeline. + + This keeps the LTX-2.3 prompt connector, x0-space CFG, sigma prompt + modulation, and audio branch semantics from ``LTX23Pipeline`` while + reusing the existing LTX image-conditioning contract: the first video + latent frame is encoded from the input image and remains fixed during + denoising. + """ + + def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): + super().__init__(od_config=od_config, prefix=prefix) + self.video_processor = VideoProcessor(vae_scale_factor=self.vae_spatial_compression_ratio, resample="bilinear") + + support_image_input = True + + _normalize_latents = staticmethod(LTX2ImageToVideoPipeline._normalize_latents) + _create_noised_state = staticmethod(LTX2ImageToVideoPipeline._create_noised_state) + + @staticmethod + def _resolve_single_prompt_image(raw_image: Any) -> Any: + if isinstance(raw_image, list): + if len(raw_image) != 1: + raise ValueError( + "LTX-2.3 I2V prompt dictionaries support exactly one image per prompt. " + "Pass one image per prompt for batched I2V requests." + ) + return raw_image[0] + return raw_image + + @staticmethod + def _resolve_additional_image(additional: dict[str, Any]) -> Any: + raw_image = additional.get("preprocessed_image") + if raw_image is None: + raw_image = additional.get("pixel_values") + if raw_image is None: + raw_image = additional.get("image") + return raw_image + + def prepare_latents( + self, + image: torch.Tensor | None = None, + batch_size: int = 1, + num_channels_latents: int = 128, + height: int = 512, + width: int = 768, + num_frames: int = 121, + noise_scale: float = 0.0, + dtype: torch.dtype | None = None, + device: torch.device | None = None, + generator: torch.Generator | list[torch.Generator] | None = None, + latents: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Prepare I2V latents and the first-frame conditioning mask. + + If caller-provided latents are used without an image, the latents must + already represent the full video state including the conditioning first + frame. Packed 3D latents are assumed to be in transformer token layout. + """ + height = height // self.vae_spatial_compression_ratio + width = width // self.vae_spatial_compression_ratio + num_frames = (num_frames - 1) // self.vae_temporal_compression_ratio + 1 + + shape = (batch_size, num_channels_latents, num_frames, height, width) + mask_shape = (batch_size, 1, num_frames, height, width) + + if latents is not None: + if latents.ndim == 5: + batch_size, _, num_frames, height, width = latents.shape + mask_shape = (batch_size, 1, num_frames, height, width) + conditioning_mask = latents.new_zeros(mask_shape) + conditioning_mask[:, :, 0] = 1.0 + + latents = self._normalize_latents( + latents, + self.vae.latents_mean, + self.vae.latents_std, + self.vae.config.scaling_factor, + ) + latents = self._create_noised_state(latents, noise_scale * (1 - conditioning_mask), generator) + latents = self._pack_latents( + latents, + self.transformer_spatial_patch_size, + self.transformer_temporal_patch_size, + ) + else: + conditioning_mask = latents.new_zeros(mask_shape) + conditioning_mask[:, :, 0] = 1.0 + + conditioning_mask = self._pack_latents( + conditioning_mask, + self.transformer_spatial_patch_size, + self.transformer_temporal_patch_size, + ).squeeze(-1) + if latents.ndim != 3 or latents.shape[:2] != conditioning_mask.shape: + raise ValueError( + "Provided `latents` tensor has shape" + f" {latents.shape}, but the expected shape is {conditioning_mask.shape + (num_channels_latents,)}." + ) + return latents.to(device=device, dtype=dtype), conditioning_mask + + if image is None: + raise ValueError("`image` must be provided when `latents` is None.") + + image_batch_size = image.shape[0] + if image_batch_size == 0: + raise ValueError("`image` batch is empty.") + if batch_size % image_batch_size != 0: + raise ValueError( + f"`batch_size` ({batch_size}) must be divisible by image batch size ({image_batch_size}) " + "for image-to-video outputs." + ) + num_videos_per_prompt = batch_size // image_batch_size + + if isinstance(generator, list): + if len(generator) != batch_size: + raise ValueError( + f"You have passed a list of generators of length {len(generator)}, but requested an effective" + f" batch size of {batch_size}. Make sure the batch size matches the length of the generators." + ) + image_generators = [generator[i * num_videos_per_prompt] for i in range(image_batch_size)] + init_latents = [ + retrieve_latents(self.vae.encode(image[i].unsqueeze(0).unsqueeze(2)), image_generators[i], "argmax") + for i in range(image_batch_size) + ] + else: + init_latents = [ + retrieve_latents(self.vae.encode(img.unsqueeze(0).unsqueeze(2)), generator, "argmax") for img in image + ] + + init_latents = torch.cat(init_latents, dim=0).to(dtype) + if num_videos_per_prompt > 1: + init_latents = init_latents.repeat_interleave(num_videos_per_prompt, dim=0) + init_latents = self._normalize_latents( + init_latents, + self.vae.latents_mean, + self.vae.latents_std, + ) + init_latents = init_latents.repeat(1, 1, num_frames, 1, 1) + + conditioning_mask = torch.zeros(mask_shape, device=device, dtype=dtype) + conditioning_mask[:, :, 0] = 1.0 + + noise = randn_tensor(shape, generator=generator, device=device, dtype=dtype) + latents = init_latents * conditioning_mask + noise * (1 - conditioning_mask) + + conditioning_mask = self._pack_latents( + conditioning_mask, + self.transformer_spatial_patch_size, + self.transformer_temporal_patch_size, + ).squeeze(-1) + latents = self._pack_latents(latents, self.transformer_spatial_patch_size, self.transformer_temporal_patch_size) + + return latents, conditioning_mask + + def check_inputs( + self, + image, + height, + width, + prompt, + latents=None, + prompt_embeds=None, + negative_prompt_embeds=None, + prompt_attention_mask=None, + negative_prompt_attention_mask=None, + ): + if image is None and latents is None: + raise ValueError("Provide either `image` or `latents`. Cannot leave both undefined.") + super().check_inputs( + prompt=prompt, + height=height, + width=width, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + ) + + _step_video_latents_i2v = LTX2ImageToVideoPipeline._step_video_latents_i2v + + def _resolve_request_image( + self, + req: DiffusionRequestBatch, + image: PIL.Image.Image | torch.Tensor | list[PIL.Image.Image | torch.Tensor] | None, + request_inputs: _LTX23RequestInputs, + ) -> PIL.Image.Image | torch.Tensor | list[PIL.Image.Image | torch.Tensor] | None: + if image is not None or not req.prompts: + return image + + raw_images = [] + for prompt_item in req.prompts: + if isinstance(prompt_item, str): + raw_image = None + else: + multi_modal_data = prompt_item.get("multi_modal_data") or {} + raw_image = multi_modal_data.get("image") + if raw_image is None: + additional = prompt_item.get("additional_information") or {} + raw_image = self._resolve_additional_image(additional) + raw_image = self._resolve_single_prompt_image(raw_image) + if isinstance(raw_image, str): + raw_image = PIL.Image.open(raw_image).convert("RGB") + raw_images.append(raw_image) + + if any(img is None for img in raw_images) and request_inputs.latents is None: + raise ValueError("Image is required for LTX-2.3 I2V generation.") + if len(raw_images) == 1: + return raw_images[0] + if raw_images: + return raw_images + return image + + def _check_forward_inputs( + self, + request_inputs: _LTX23RequestInputs, + image: Any | None = None, + ) -> None: + self.check_inputs( + image=image, + height=request_inputs.height, + width=request_inputs.width, + prompt=request_inputs.prompt, + latents=request_inputs.latents, + prompt_embeds=request_inputs.prompt_embeds, + negative_prompt_embeds=request_inputs.negative_prompt_embeds, + prompt_attention_mask=request_inputs.prompt_attention_mask, + negative_prompt_attention_mask=request_inputs.negative_prompt_attention_mask, + ) + + def _resolve_video_latent_dimensions( + self, + request_inputs: _LTX23RequestInputs, + ) -> tuple[int, int, int]: + latent_num_frames = (request_inputs.num_frames - 1) // self.vae_temporal_compression_ratio + 1 + latent_height = request_inputs.height // self.vae_spatial_compression_ratio + latent_width = request_inputs.width // self.vae_spatial_compression_ratio + if request_inputs.latents is not None: + if request_inputs.latents.ndim == 5: + _, _, latent_num_frames, latent_height, latent_width = request_inputs.latents.shape + elif request_inputs.latents.ndim != 3: + raise ValueError( + f"Provided `latents` tensor has shape {request_inputs.latents.shape}, but the expected shape is " + "either [batch_size, seq_len, num_features] or " + "[batch_size, latent_dim, latent_frames, latent_height, latent_width]." + ) + return latent_num_frames, latent_height, latent_width + + def _prepare_video_latents_stage( + self, + request_inputs: _LTX23RequestInputs, + prompt_context: _LTX23PromptContext, + *, + device: torch.device, + noise_scale: float, + image: Any | None = None, + ) -> tuple[torch.Tensor, torch.Tensor | None]: + if request_inputs.latents is None: + if isinstance(image, torch.Tensor): + if image.ndim == 3: + image = image.unsqueeze(0) + elif isinstance(image, list) and image and isinstance(image[0], torch.Tensor): + image = torch.stack(image, dim=0) + else: + image = self.video_processor.preprocess( + image, + height=request_inputs.height, + width=request_inputs.width, + ) + image = image.to(device=device, dtype=prompt_context.positive_connector_prompt_embeds.dtype) + + num_channels_latents = self.transformer.config.in_channels + return self.prepare_latents( + image, + prompt_context.batch_size * request_inputs.num_videos_per_prompt, + num_channels_latents, + request_inputs.height, + request_inputs.width, + request_inputs.num_frames, + noise_scale, + torch.float32, + device, + request_inputs.generator, + request_inputs.latents, + ) + + def _make_video_audio_scheduler( + self, + audio_scheduler: Any, + latent_num_frames: int, + latent_height: int, + latent_width: int, + ) -> Any: + return _I2VVideoAudioScheduler( + self, + audio_scheduler, + latent_num_frames, + latent_height, + latent_width, + ) + + def _prepare_denoise_context_for_cfg( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + ) -> _LTX23DenoiseContext: + denoise_ctx = super()._prepare_denoise_context_for_cfg(forward_ctx, denoise_ctx) + if denoise_ctx.conditioning_mask is None: + raise ValueError("I2V denoising requires a conditioning mask.") + if self.do_classifier_free_guidance and not forward_ctx.cfg_parallel_ready: + denoise_ctx.conditioning_mask_for_model = torch.cat( + [denoise_ctx.conditioning_mask, denoise_ctx.conditioning_mask] + ) + else: + denoise_ctx.conditioning_mask_for_model = denoise_ctx.conditioning_mask + return denoise_ctx + + def _denoise_timestep_kwargs( + self, + ts: torch.Tensor, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + ) -> dict[str, torch.Tensor]: + conditioning_mask = ( + denoise_ctx.conditioning_mask if forward_ctx.cfg_parallel_ready else denoise_ctx.conditioning_mask_for_model + ) + if conditioning_mask is None: + raise ValueError("I2V denoising requires a conditioning mask.") + return { + "timestep": ts.unsqueeze(-1) * (1 - conditioning_mask), + "audio_timestep": ts, + "sigma": ts, + } + + def _step_denoised_latents( + self, + forward_ctx: _LTX23ForwardContext, + denoise_ctx: _LTX23DenoiseContext, + noise_pred_video: torch.Tensor, + noise_pred_audio: torch.Tensor, + t: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + latents, audio_latents = self.scheduler_step_maybe_with_cfg( + (noise_pred_video, noise_pred_audio), + (t, t), + (denoise_ctx.latents, denoise_ctx.audio_latents), + do_true_cfg=self.do_classifier_free_guidance, + per_request_scheduler=forward_ctx.video_audio_scheduler, + ) + return self._synchronize_cfg_parallel_step_output( + (latents, audio_latents), + do_true_cfg=self.do_classifier_free_guidance, + ) + + @torch.no_grad() + def forward( + self, + req: DiffusionRequestBatch, + image: PIL.Image.Image | torch.Tensor | list[PIL.Image.Image | torch.Tensor] | None = None, + prompt: str | list[str] | None = None, + negative_prompt: str | list[str] | None = None, + height: int | None = None, + width: int | None = None, + num_frames: int | None = None, + frame_rate: float | None = None, + num_inference_steps: int | None = None, + sigmas: list[float] | None = None, + timesteps: list[int] | None = None, + guidance_scale: float = 4.0, + noise_scale: float = 0.0, + num_videos_per_prompt: int | None = 1, + generator: torch.Generator | list[torch.Generator] | None = None, + latents: torch.Tensor | None = None, + audio_latents: torch.Tensor | None = None, + prompt_embeds: torch.Tensor | None = None, + negative_prompt_embeds: torch.Tensor | None = None, + prompt_attention_mask: torch.Tensor | None = None, + negative_prompt_attention_mask: torch.Tensor | None = None, + decode_timestep: float | list[float] = 0.0, + decode_noise_scale: float | list[float] | None = None, + output_type: str = "np", + return_dict: bool = True, + attention_kwargs: dict[str, Any] | None = None, + max_sequence_length: int | None = None, + ) -> list[DiffusionOutput]: + request_inputs = self._resolve_request_inputs( + req, + prompt=prompt, + negative_prompt=negative_prompt, + height=height, + width=width, + num_frames=num_frames, + frame_rate=frame_rate, + num_inference_steps=num_inference_steps, + timesteps=timesteps, + guidance_scale=guidance_scale, + num_videos_per_prompt=num_videos_per_prompt, + generator=generator, + latents=latents, + audio_latents=audio_latents, + prompt_embeds=prompt_embeds, + negative_prompt_embeds=negative_prompt_embeds, + prompt_attention_mask=prompt_attention_mask, + negative_prompt_attention_mask=negative_prompt_attention_mask, + decode_timestep=decode_timestep, + decode_noise_scale=decode_noise_scale, + output_type=output_type, + max_sequence_length=max_sequence_length, + ) + image = self._resolve_request_image(req, image, request_inputs) + return self._forward_impl( + req, + request_inputs, + noise_scale=noise_scale, + sigmas=sigmas, + timesteps=timesteps, + attention_kwargs=attention_kwargs, + image=image, + ) + __all__ = [ "LTX23ImageToVideoPipeline", From d9cf85d5e2ee8052672a671dc1ab19ec927dd920 Mon Sep 17 00:00:00 2001 From: akshatvishu Date: Fri, 3 Jul 2026 09:41:28 +0530 Subject: [PATCH 009/101] [BugFix] Use KV cache scale mapper in custom loaders (#4810) Signed-off-by: akshatvishu --- .../models/test_kv_cache_scale_mapper.py | 148 ++++++++++++++++++ .../models/hunyuan_image3/hunyuan_image3.py | 10 +- .../models/mammoth_moda2/mammoth_moda2.py | 12 +- .../models/mimo_audio/mimo_audio_llm.py | 13 +- .../models/qwen2_5_omni/qwen2_old.py | 9 -- 5 files changed, 159 insertions(+), 33 deletions(-) create mode 100644 tests/model_executor/models/test_kv_cache_scale_mapper.py diff --git a/tests/model_executor/models/test_kv_cache_scale_mapper.py b/tests/model_executor/models/test_kv_cache_scale_mapper.py new file mode 100644 index 000000000..98f016bb4 --- /dev/null +++ b/tests/model_executor/models/test_kv_cache_scale_mapper.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for vLLM's KV-cache scale mapper load path.""" + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_STALE_API_FILES = [ + "vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py", + "vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py", + "vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py", + "vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py", +] + +_SOURCE_SCALE_NAME = "layers.0.self_attn.k_proj.output_scale" +_MAPPED_SCALE_NAME = "layers.0.self_attn.attn.k_scale" + + +class _RecordingCacheScaleMapper: + def __init__(self) -> None: + self.called = False + + def apply( + self, + weights: Iterable[tuple[str, torch.Tensor]], + ) -> Iterable[tuple[str, torch.Tensor]]: + self.called = True + for name, weight in weights: + yield name.replace(".k_proj.output_scale", ".attn.k_scale"), weight + + +class _QuantConfigWithoutOldCacheScale: + def __init__(self) -> None: + self.mapper = _RecordingCacheScaleMapper() + + def get_cache_scale_mapper(self) -> _RecordingCacheScaleMapper: + return self.mapper + + +class _FakeModel: + def __init__(self, quant_config: _QuantConfigWithoutOldCacheScale) -> None: + self.quant_config = quant_config + self.param = torch.nn.Parameter(torch.zeros(()), requires_grad=False) + + def named_parameters( + self, + remove_duplicate: bool = True, + ) -> Iterable[tuple[str, torch.nn.Parameter]]: + del remove_duplicate + return [(_MAPPED_SCALE_NAME, self.param)] + + +class _FakeHunyuanModel(_FakeModel): + def __init__(self, quant_config: _QuantConfigWithoutOldCacheScale) -> None: + super().__init__(quant_config) + self.config = SimpleNamespace( + tie_word_embeddings=False, + num_attention_heads=1, + num_key_value_heads=1, + use_cla=False, + ) + + def get_expert_mapping(self) -> tuple[list[object], dict[str, object]]: + return [], {} + + def _split_qkv_weight(self, weight: torch.Tensor) -> torch.Tensor: + return weight + + +@pytest.mark.parametrize("rel_path", _STALE_API_FILES) +def test_model_loaders_do_not_call_removed_get_cache_scale(rel_path: str) -> None: + """Affected loaders must not call the removed vLLM get_cache_scale API.""" + source = (_REPO_ROOT / rel_path).read_text(encoding="utf-8") + assert ".get_cache_scale(" not in source + + +@pytest.mark.parametrize( + ("module_name", "class_name", "fake_model_cls"), + [ + ( + "vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3", + "HunyuanModel", + _FakeHunyuanModel, + ), + ( + "vllm_omni.model_executor.models.qwen2_5_omni.qwen2_old", + "Qwen2Model", + _FakeModel, + ), + ], +) +def test_auto_weights_loader_delegated_loaders_accept_mapped_cache_scale( + module_name: str, + class_name: str, + fake_model_cls: type[_FakeModel], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Delegated loaders must not expect the removed get_cache_scale method.""" + module = __import__(module_name, fromlist=[class_name]) + monkeypatch.setattr(module, "is_pp_missing_parameter", lambda *_: False) + + fake_model = fake_model_cls(_QuantConfigWithoutOldCacheScale()) + load_weights = getattr(module, class_name).load_weights + + loaded = load_weights(fake_model, [(_MAPPED_SCALE_NAME, torch.ones(()))]) + + assert loaded == {_MAPPED_SCALE_NAME} + torch.testing.assert_close(fake_model.param.data, torch.ones(())) + + +@pytest.mark.parametrize( + ("module_name", "class_name"), + [ + ( + "vllm_omni.model_executor.models.mammoth_moda2.mammoth_moda2", + "MammothModa2Qwen2ForCausalLM", + ), + ( + "vllm_omni.model_executor.models.mimo_audio.mimo_audio_llm", + "MiMoAudioLLMForConditionalGeneration", + ), + ], +) +def test_direct_custom_loaders_apply_cache_scale_mapper( + module_name: str, + class_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Direct custom loaders must apply the new mapper before fall-through load.""" + module = __import__(module_name, fromlist=[class_name]) + monkeypatch.setattr(module, "is_pp_missing_parameter", lambda *_: False) + + quant_config = _QuantConfigWithoutOldCacheScale() + fake_model = _FakeModel(quant_config) + load_weights = getattr(module, class_name).load_weights + + loaded = load_weights(fake_model, [(_SOURCE_SCALE_NAME, torch.ones(()))]) + + assert quant_config.mapper.called + assert loaded == {_MAPPED_SCALE_NAME} + torch.testing.assert_close(fake_model.param.data, torch.ones(())) 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 d9efccd9a..17bbad001 100755 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -222,14 +222,8 @@ class HunyuanModel(HunYuanModel): if self.config.tie_word_embeddings and "lm_head.weight" in name: continue - if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)): - # Loading kv cache scales for compressed-tensors quantization - param = params_dict[scale_name] - weight_loader = getattr(param, "weight_loader", default_weight_loader) - loaded_weight = loaded_weight[0] - weight_loader(param, loaded_weight) - continue - + # KV-cache scales are renamed to .attn.{k,v,q}_scale by the + # quant_config mapper in the outer AutoWeightsLoader. is_found = False for param_name, weight_name, shard_id in stacked_params_mapping: if weight_name not in name: diff --git a/vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py b/vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py index 6311216dc..2fe616cfd 100644 --- a/vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py +++ b/vllm_omni/model_executor/models/mammoth_moda2/mammoth_moda2.py @@ -483,19 +483,15 @@ class MammothModa2Qwen2ForCausalLM(nn.Module, SupportsPP): ] params_dict = dict(self.named_parameters(remove_duplicate=False)) loaded_params: set[str] = set() + if self.quant_config is not None: + cache_scale_mapper = self.quant_config.get_cache_scale_mapper() + if cache_scale_mapper is not None: + weights = cache_scale_mapper.apply(weights) for name, loaded_weight in weights: if "rotary_emb.inv_freq" in name: continue - if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)): - param = params_dict[scale_name] - weight_loader = getattr(param, "weight_loader", default_weight_loader) - loaded_weight = loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0] - weight_loader(param, loaded_weight) - loaded_params.add(scale_name) - continue - for param_name, weight_name, shard_id in stacked_params_mapping: if weight_name not in name: continue diff --git a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py index a640ade3a..48715fb98 100644 --- a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py +++ b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py @@ -1153,17 +1153,14 @@ class MiMoAudioLLMForConditionalGeneration(nn.Module, SupportsMultiModal, Suppor params_dict = dict(self.named_parameters(remove_duplicate=False)) loaded_params: set[str] = set() + if self.quant_config is not None: + cache_scale_mapper = self.quant_config.get_cache_scale_mapper() + if cache_scale_mapper is not None: + weights = cache_scale_mapper.apply(weights) + for name, loaded_weight in weights: if name.startswith("model."): name = "model." + name - if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)): - # Loading kv cache quantization scales - param = params_dict[scale_name] - weight_loader = getattr(param, "weight_loader", default_weight_loader) - loaded_weight = loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0] - weight_loader(param, loaded_weight) - loaded_params.add(scale_name) - continue for param_name, weight_name, shard_id in stacked_params_mapping: if name.startswith("input_local_transformer.") or name.startswith("local_transformer."): diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py index 67806f951..91eafd2a8 100644 --- a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py +++ b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py @@ -335,19 +335,10 @@ class Qwen2Model(nn.Module): ("gate_up_proj", "up_proj", 1), ] params_dict = dict(self.named_parameters(remove_duplicate=False)) - get_cache_scale = getattr(self.quant_config, "get_cache_scale", None) loaded_params: set[str] = set() for name, loaded_weight in weights: if "rotary_emb.inv_freq" in name: continue - if get_cache_scale is not None and (scale_name := get_cache_scale(name)): - # Loading kv cache quantization scales - param = params_dict[scale_name] - weight_loader = getattr(param, "weight_loader", default_weight_loader) - loaded_weight = loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0] - weight_loader(param, loaded_weight) - loaded_params.add(scale_name) - continue for param_name, weight_name, shard_id in stacked_params_mapping: if weight_name not in name: continue From 0b59dfb94a670c79f637b5bbf08e7fcc4e3af7e7 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:15:32 +0800 Subject: [PATCH 010/101] [Bugfix] Fix pytest 9.1.0 duplicate parametrization in run_benchmark.py (#4784) Signed-off-by: wangyu <410167048@qq.com> --- tests/dfx/perf/scripts/run_benchmark.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dfx/perf/scripts/run_benchmark.py b/tests/dfx/perf/scripts/run_benchmark.py index 7ca376323..13329a209 100644 --- a/tests/dfx/perf/scripts/run_benchmark.py +++ b/tests/dfx/perf/scripts/run_benchmark.py @@ -94,7 +94,7 @@ def omni_server(request): benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) -@pytest.fixture(params=benchmark_indices) +@pytest.fixture def benchmark_params(request, omni_server): """Benchmark parameters fixture with proper parametrization""" test_name, param_index = request.param From 04f400d36c5ffec79d6a02545809ce570d035245 Mon Sep 17 00:00:00 2001 From: akshatvishu Date: Fri, 3 Jul 2026 10:02:22 +0530 Subject: [PATCH 011/101] [Bugfix] Report stepwise Qwen-Image profiler metrics (#4517) Signed-off-by: akshatvishu --- .../diffusion/test_diffusion_step_pipeline.py | 108 ++++++++++++++++++ .../profiler/diffusion_pipeline_profiler.py | 11 +- .../worker/diffusion_model_runner.py | 51 ++++++++- vllm_omni/diffusion/worker/utils.py | 38 ++++++ 4 files changed, 200 insertions(+), 8 deletions(-) diff --git a/tests/diffusion/test_diffusion_step_pipeline.py b/tests/diffusion/test_diffusion_step_pipeline.py index bd2289283..7e18996ed 100644 --- a/tests/diffusion/test_diffusion_step_pipeline.py +++ b/tests/diffusion/test_diffusion_step_pipeline.py @@ -30,6 +30,9 @@ from vllm_omni.diffusion.ipc import ( pack_diffusion_output_shm, unpack_diffusion_output_shm, ) +from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import ( + DiffusionPipelineProfilerMixin, +) from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched import StepScheduler from vllm_omni.diffusion.sched.interface import ( @@ -97,6 +100,51 @@ class _StepPipeline: return DiffusionOutput(output=torch.tensor([state.step_index], dtype=torch.float32)) +class _ProfilingStepPipeline(_StepPipeline): + enable_diffusion_pipeline_profiler = True + + def __init__(self): + super().__init__() + self._stage_durations: dict[str, float] = {} + + @property + def stage_durations(self) -> dict[str, float]: + return dict(self._stage_durations) + + def clear_profiler_records(self) -> None: + self._stage_durations.clear() + + def prepare_encode(self, state, **kwargs): + result = super().prepare_encode(state, **kwargs) + self._stage_durations["QwenImagePipeline.text_encoder.forward"] = 1.0 + return result + + def denoise_step(self, input_batch, **kwargs): + result = super().denoise_step(input_batch, **kwargs) + self._stage_durations["QwenImagePipeline.diffuse"] = 2.0 + return result + + def post_decode(self, state, **kwargs): + result = super().post_decode(state, **kwargs) + self._stage_durations["QwenImagePipeline.vae.decode"] = 3.0 + return result + + +class _AutoDenoiseProfilerPipeline(DiffusionPipelineProfilerMixin): + _PROFILER_TARGETS: list[str] = [] + + def __init__(self): + self.setup_diffusion_pipeline_profiler( + enable_diffusion_pipeline_profiler=True, + ) + + def forward(self): + return None + + def denoise_step(self): + return "ok" + + class _InterruptingStepPipeline(_StepPipeline): interrupt = True @@ -130,6 +178,9 @@ class _FakePeakMemoryPlatform: index = min(self.reset_calls - 1, len(self._reserved_mb) - 1) return int((self._reserved_mb[index] - 100) * 1024**2) + def is_available(self) -> bool: + return True + class _IdentityNoiseTransformer(torch.nn.Module): def forward(self, x: torch.Tensor, **kwargs): @@ -437,6 +488,16 @@ def test_input_batch_cached_repack_keeps_static_prompt_fields_for_same_compositi torch.testing.assert_close(repacked.prompt_embeds, torch.ones(1, 2, 3)) +@pytest.mark.cpu +def test_step_profiler_reports_denoise_step_as_diffuse(): + pipeline = _AutoDenoiseProfilerPipeline() + + assert pipeline.denoise_step() == "ok" + + assert any(key.endswith(".diffuse") for key in pipeline.stage_durations) + assert not any(key.endswith(".denoise_step") for key in pipeline.stage_durations) + + @pytest.mark.cpu class TestRunner: """DiffusionModelRunner.execute_stepwise""" @@ -469,6 +530,53 @@ class TestRunner: assert runner.pipeline.scheduler_calls == 2 assert runner.pipeline.decode_calls == 1 + def test_stepwise_output_includes_stage_and_peak_metrics(self, monkeypatch): + runner = _make_runner() + runner.pipeline = _ProfilingStepPipeline() + req = _make_step_request() + reset_calls = [] + monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) + monkeypatch.setattr( + model_runner_module.current_omni_platform, + "is_available", + lambda: True, + ) + monkeypatch.setattr( + model_runner_module.current_omni_platform, + "reset_peak_memory_stats", + lambda: reset_calls.append(True), + ) + monkeypatch.setattr( + model_runner_module.current_omni_platform, + "max_memory_reserved", + lambda: 2 * 1024**2, + ) + monkeypatch.setattr( + model_runner_module.current_omni_platform, + "max_memory_allocated", + lambda: 1024**2, + ) + + DiffusionModelRunner.execute_stepwise( + runner, + _make_scheduler_output(req, step_id=0), + ) + result = DiffusionModelRunner.execute_stepwise( + runner, + _make_cached_scheduler_output(step_id=1), + ) + + output = result.get_request_output("req-1") + assert output.finished is True + assert output.result is not None + assert output.result.peak_memory_mb == 2 + assert output.result.stage_durations == { + "QwenImagePipeline.text_encoder.forward": 1.0, + "QwenImagePipeline.diffuse": 4.0, + "QwenImagePipeline.vae.decode": 3.0, + } + assert reset_calls == [True] + def test_carries_peak_memory_across_stepwise_request_lifecycle(self, monkeypatch): runner = _make_runner() req = _make_step_request() diff --git a/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py b/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py index 8d4bf41de..5a293b905 100644 --- a/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py +++ b/vllm_omni/diffusion/profiler/diffusion_pipeline_profiler.py @@ -14,6 +14,7 @@ logger = init_logger(__name__) def profiler(name: str, func: Callable, instance: Any) -> Callable: """Timing a function execution.""" + metric_name = f"{name.rsplit('.', 1)[0]}.diffuse" if name.endswith(".denoise_step") else name @functools.wraps(func) def wrapper(*args, **kwargs) -> Any: @@ -28,10 +29,10 @@ def profiler(name: str, func: Callable, instance: Any) -> Callable: if current_omni_platform.is_available(): current_omni_platform.synchronize() duration = time.perf_counter() - start_time - logger.info(f"[DiffusionPipelineProfiler] {name} took {duration:.6f}s") + logger.info(f"[DiffusionPipelineProfiler] {metric_name} took {duration:.6f}s") # record the profiling data: duration of stages with instance._profiler_lock: - instance._stage_durations[name] = instance._stage_durations.get(name, 0.0) + duration + instance._stage_durations[metric_name] = instance._stage_durations.get(metric_name, 0.0) + duration return wrapper @@ -93,7 +94,11 @@ class DiffusionPipelineProfilerMixin: self._stage_durations: dict[str, float] = {} targets = profiler_targets if profiler_targets is not None else self._PROFILER_TARGETS if not targets: - return + targets = [] + else: + targets = list(targets) + if not profiler_targets and hasattr(self, "denoise_step"): + targets.append("denoise_step") targets = ["forward"] + [ t for t in targets if t != "forward" diff --git a/vllm_omni/diffusion/worker/diffusion_model_runner.py b/vllm_omni/diffusion/worker/diffusion_model_runner.py index 84eb32044..cb16bdeac 100644 --- a/vllm_omni/diffusion/worker/diffusion_model_runner.py +++ b/vllm_omni/diffusion/worker/diffusion_model_runner.py @@ -39,7 +39,15 @@ from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput from vllm_omni.diffusion.worker.input_batch import InputBatch, scatter_latents from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch -from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, DiffusionRequestState, RunnerOutput +from vllm_omni.diffusion.worker.utils import ( + BatchRunnerOutput, + DiffusionRequestState, + RunnerOutput, + attach_stage_durations, + clear_pipeline_stage_durations, + consume_pipeline_stage_durations, + merge_stage_durations, +) from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager from vllm_omni.platforms import current_omni_platform from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin @@ -487,6 +495,19 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): return self._runner_output_from_outputs(reqs, outputs) + def _attach_stepwise_metrics( + self, + state: DiffusionRequestState, + output: DiffusionOutput, + *, + is_primary: bool, + ) -> None: + merge_stage_durations( + state, + consume_pipeline_stage_durations(self.pipeline), + ) + attach_stage_durations(state, output) + def execute_model(self, req: OmniDiffusionRequest, kv_prefetch_jobs: dict | None = None) -> DiffusionOutput: """ Execute a forward pass for the given requests. @@ -601,8 +622,13 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): else: gen_device = self.device state.sampling.generator = torch.Generator(device=gen_device).manual_seed(state.sampling.seed) + clear_pipeline_stage_durations(self.pipeline) # encode self.pipeline.prepare_encode(state) + merge_stage_durations( + state, + consume_pipeline_stage_durations(self.pipeline), + ) input_batch = InputBatch.make_batch( states, @@ -658,19 +684,27 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): use_hsdp = self.od_config.parallel_config.use_hsdp grad_context = torch.no_grad() if use_hsdp else torch.inference_mode() with grad_context: + had_active_states = bool(self.state_cache) states, new_request_ids = self._update_states(scheduler_output) + is_primary = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0 + if new_request_ids and not had_active_states and is_primary and current_omni_platform.is_available(): + current_omni_platform.reset_peak_memory_stats() input_batch = self._prepare_batch_inputs(states, new_request_ids) attn_metadata = self._prepare_attn_metadata(input_batch) - is_primary = not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0 - if is_primary: - current_omni_platform.reset_peak_memory_stats() with set_forward_context( vllm_config=self.vllm_config, omni_diffusion_config=self.od_config, attn_metadata=attn_metadata, ): + clear_pipeline_stage_durations(self.pipeline) noise_pred = self.pipeline.denoise_step(input_batch, states=states) + denoise_stage_durations = consume_pipeline_stage_durations(self.pipeline) + for state in states: + merge_stage_durations( + state, + denoise_stage_durations, + ) runner_output_list = [] pipeline_interrupted = getattr(self.pipeline, "interrupt", False) @@ -693,14 +727,20 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): self.pipeline.step_scheduler( req, noise_pred[offset : offset + row_num] if noise_pred is not None else None ) - offset = offset + row_num if self.od_config.streaming_output: should_decode = req.chunk_denoise_completed else: should_decode = req.denoise_completed if should_decode: + clear_pipeline_stage_durations(self.pipeline) result = self.pipeline.post_decode(req) + if result is not None: + self._attach_stepwise_metrics( + req, + result, + is_primary=is_primary, + ) else: result = None # finished should be computed after post_decode() advanced chunk_index @@ -717,6 +757,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): result=result, ) ) + offset = offset + row_num except Exception as per_req_exc: offset = offset + row_num logger.error( diff --git a/vllm_omni/diffusion/worker/utils.py b/vllm_omni/diffusion/worker/utils.py index 4fd374d34..ba6165a9d 100644 --- a/vllm_omni/diffusion/worker/utils.py +++ b/vllm_omni/diffusion/worker/utils.py @@ -15,6 +15,41 @@ if TYPE_CHECKING: from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType +def clear_pipeline_stage_durations(pipeline: Any) -> None: + clear_records = getattr(pipeline, "clear_profiler_records", None) + if getattr(pipeline, "enable_diffusion_pipeline_profiler", False) and callable(clear_records): + clear_records() + + +def consume_pipeline_stage_durations(pipeline: Any) -> dict[str, float]: + if not getattr(pipeline, "enable_diffusion_pipeline_profiler", False): + return {} + stage_durations = getattr(pipeline, "stage_durations", None) + if not isinstance(stage_durations, dict): + return {} + result = {stage: float(duration) for stage, duration in stage_durations.items()} + clear_pipeline_stage_durations(pipeline) + return result + + +def merge_stage_durations( + state: DiffusionRequestState, + stage_durations: dict[str, float], +) -> None: + if not stage_durations: + return + for stage, duration in stage_durations.items(): + state.stage_durations[stage] = float(state.stage_durations.get(stage, 0.0)) + float(duration) + + +def attach_stage_durations( + state: DiffusionRequestState, + output: DiffusionOutput, +) -> None: + if state.stage_durations: + output.stage_durations = dict(state.stage_durations) + + @dataclass class DiffusionRequestState: """Per-request mutable state across all pipeline stages. @@ -79,6 +114,9 @@ class DiffusionRequestState: # For example: Wan condition tensors / masks, or Bagel KV contexts. extra: dict[str, Any] = field(default_factory=dict) + # ── Runner-owned profiling metadata ── + stage_durations: dict[str, float] = field(default_factory=dict) + # Peak device memory observed while this request is active in step mode. peak_memory_mb: float = 0.0 From 0455bffc3768e1fe616fea37885fb0a84cd5985b Mon Sep 17 00:00:00 2001 From: Aleksi Vesanto Date: Fri, 3 Jul 2026 08:17:32 +0300 Subject: [PATCH 012/101] [ROCm][Perf] Add ROCm support for MXFP4 (w4a4) online quantization (#4104) Signed-off-by: Aleksi Vesanto Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com> --- docs/user_guide/quantization/mxfp4.md | 2 +- docs/user_guide/quantization/overview.md | 2 +- .../quantization/test_mxfp4_config.py | 115 +++++++++++++ vllm_omni/quantization/mxfp4_config.py | 156 +++++++++++++++++- 4 files changed, 272 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/quantization/mxfp4.md b/docs/user_guide/quantization/mxfp4.md index 401a55ad4..b18b3e7b3 100644 --- a/docs/user_guide/quantization/mxfp4.md +++ b/docs/user_guide/quantization/mxfp4.md @@ -39,7 +39,7 @@ vLLM-Omni provides two quantization methods with different scale structures: | NVIDIA Blackwell GPU (SM 100+) | ⭕ | | NVIDIA Ada/Hopper GPU (SM 89+) | ⭕ | | NVIDIA Ampere GPU (SM 80+) | ⭕ | -| AMD ROCm | ⭕ | +| AMD ROCm (gfx950 / MI355X) | ✅ | | Intel XPU | ⭕ | | Ascend NPU (Atlas 950 A5) | ✅ | diff --git a/docs/user_guide/quantization/overview.md b/docs/user_guide/quantization/overview.md index 38d027f12..3a4a45573 100644 --- a/docs/user_guide/quantization/overview.md +++ b/docs/user_guide/quantization/overview.md @@ -20,7 +20,7 @@ type has a different quantization scope. | NVIDIA Blackwell GPU (SM 100+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ | | NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | ✅ | ✅ | ⭕ | ⭕ | ✅ | ✅ | ❌ | | NVIDIA Ampere GPU (SM 80+) | ✅ | ✅ | ⭕ | ⭕ | ⭕ | ✅ | ✅ | ❌ | -| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ❌ | +| AMD ROCm | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ⭕ | ⭕ | ❌ | | Intel XPU | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ⭕ | ✅ | ❌ | | Ascend NPU | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | diff --git a/tests/diffusion/quantization/test_mxfp4_config.py b/tests/diffusion/quantization/test_mxfp4_config.py index af3a9c9c4..240ee2509 100644 --- a/tests/diffusion/quantization/test_mxfp4_config.py +++ b/tests/diffusion/quantization/test_mxfp4_config.py @@ -537,3 +537,118 @@ def test_single_scale_row_parallel_tp2_loader_simulation(): for rank in range(_TP2): assert _shard(ckpt_weight_scale, layer.weight_scale, rank, _TP2, "input_dim").shape == layer.weight_scale.shape + + +# --------------------------------------------------------------------------- +# ROCm MXFP4 (gfx950) — get_quant_method dispatch +# +# These run on CPU: the platform check, gcnArchName probe and aiter op +# registration are all mocked (stdlib unittest.mock + monkeypatch, so no +# pytest-mock dependency). They cover only the dispatch + weight-allocation +# logic added by the ROCm PR — the AITER GEMM / quant kernels require real +# gfx950 hardware and are intentionally NOT exercised here. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _rocm_platform(monkeypatch: pytest.MonkeyPatch): + """Make current_omni_platform report ROCm, and stub the aiter + custom-op registration so ROCmMxfp4*Method can be constructed without aiter.""" + from vllm_omni.platforms import current_omni_platform + from vllm_omni.quantization import mxfp4_config + + monkeypatch.setattr(current_omni_platform, "is_npu", lambda: False) + monkeypatch.setattr(current_omni_platform, "is_rocm", lambda: True) + monkeypatch.setattr(mxfp4_config, "_register_rocm_mxfp4_op", lambda: None) + + +def _patch_gcn_arch(monkeypatch: pytest.MonkeyPatch, arch: str) -> None: + """Patch torch.cuda.get_device_properties(...).gcnArchName to return `arch`.""" + from types import SimpleNamespace + + monkeypatch.setattr(torch.accelerator, "current_device_index", lambda: 0) + monkeypatch.setattr( + torch.cuda, + "get_device_properties", + lambda *a, **k: SimpleNamespace(gcnArchName=arch), + ) + + +def _fake_linear_layer(): + """A stand-in that passes isinstance(layer, LinearBase) without a real layer.""" + from unittest.mock import MagicMock + + from vllm.model_executor.layers.linear import LinearBase + + return MagicMock(spec=LinearBase) + + +def test_rocm_online_dispatch_returns_rocm_method(_rocm_platform, monkeypatch): + """ROCm + gfx950 + online checkpoint must return ROCmMxfp4OnlineLinearMethod.""" + from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod + + _patch_gcn_arch(monkeypatch, "gfx950:sramecc+:xnack-") + cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False) + + method = cfg.get_quant_method(_fake_linear_layer(), "blocks.0.attn1.to_q") + assert isinstance(method, ROCmMxfp4OnlineLinearMethod) + + +def test_rocm_ignored_layer_returns_unquantized(_rocm_platform, monkeypatch): + """A prefix in ignored_layers must return UnquantizedLinearMethod before the gfx950 probe.""" + from vllm.model_executor.layers.linear import UnquantizedLinearMethod + + from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config + + _patch_gcn_arch(monkeypatch, "gfx950:sramecc+:xnack-") + cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False, ignored_layers=["proj_out"]) + + assert isinstance(cfg.get_quant_method(_fake_linear_layer(), "proj_out"), UnquantizedLinearMethod) + + +def test_rocm_non_gfx950_raises(_rocm_platform, monkeypatch): + """MXFP4 on ROCm requires gfx950; any other arch must raise.""" + from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config + + _patch_gcn_arch(monkeypatch, "gfx942:sramecc+:xnack-") + cfg = DiffusionMXFP4Config(is_checkpoint_mxfp4_serialized=False) + + with pytest.raises(NotImplementedError, match="gfx950"): + cfg.get_quant_method(_fake_linear_layer(), "blocks.0.attn1.to_q") + + +# --------------------------------------------------------------------------- +# ROCm MXFP4 — create_weights (online lazy meta-device placeholder) +# +# The base ROCmMxfp4LinearMethod is abstract (no create_weights); the online +# subclass gets create_weights from _LazyWeightMixin, which registers a BF16 +# weight on the meta device to be materialised at load time. +# --------------------------------------------------------------------------- + + +def test_rocm_create_weights_column_parallel_tp2(_rocm_platform): + """Column-parallel TP=2: meta BF16 weight has the output halved, input full.""" + from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod + + method = ROCmMxfp4OnlineLinearMethod(DiffusionMXFP4Config()) + layer = _create_weights(method, input_size_per_partition=_TP2_K, output_partition_sizes=[_TP2_N // _TP2]) + + assert layer.weight.shape == (_TP2_N // _TP2, _TP2_K) + assert layer.weight.dtype == torch.bfloat16 + assert layer.weight.device.type == "meta" + assert layer.weight.input_dim == 1 + assert layer.weight.output_dim == 0 + assert layer.logical_widths == [_TP2_N // _TP2] + assert layer.weight_block_size is None + + +def test_rocm_create_weights_row_parallel_tp2(_rocm_platform): + """Row-parallel TP=2: meta BF16 weight has the input halved, output full.""" + from vllm_omni.quantization.mxfp4_config import DiffusionMXFP4Config, ROCmMxfp4OnlineLinearMethod + + method = ROCmMxfp4OnlineLinearMethod(DiffusionMXFP4Config()) + layer = _create_weights(method, input_size_per_partition=_TP2_K // _TP2, output_partition_sizes=[_TP2_N]) + + assert layer.weight.shape == (_TP2_N, _TP2_K // _TP2) + assert layer.input_size_per_partition == _TP2_K // _TP2 + assert layer.output_size_per_partition == _TP2_N diff --git a/vllm_omni/quantization/mxfp4_config.py b/vllm_omni/quantization/mxfp4_config.py index 6a02d5edb..a6b29ea79 100644 --- a/vllm_omni/quantization/mxfp4_config.py +++ b/vllm_omni/quantization/mxfp4_config.py @@ -7,6 +7,8 @@ Architecture mirrors mxfp8_config.py: MXFPLinearMethodBase – platform-agnostic skeleton (imported from mxfp8_config) NPUMxfp4LinearMethod – NPU single-scale offline (W4A4 MXFP4) NPUMxfp4OnlineLinearMethod – NPU single-scale online (BF16 → FP4) + ROCmMxfp4LinearMethod – ROCm base class (AITER quant + shuffle; online-only) + ROCmMxfp4OnlineLinearMethod – ROCm online (BF16 → FP4 via AITER) NPUMxfp4DualScaleLinearMethod – NPU dual-scale offline (W4A4 MXFP4 DualScale) NPUMxfp4DualScaleOnlineLinearMethod – NPU dual-scale online (BF16 → FP4) @@ -153,9 +155,16 @@ class DiffusionMXFP4Config(QuantizationConfig): if self.is_checkpoint_mxfp4_serialized: return NPUMxfp4LinearMethod(self) return NPUMxfp4OnlineLinearMethod(self) + if current_omni_platform.is_rocm(): + gcn_arch = torch.cuda.get_device_properties(torch.accelerator.current_device_index()).gcnArchName + if "gfx950" not in gcn_arch: + raise NotImplementedError(f"MXFP4 on ROCm requires gfx950 (MI355X). Detected: {gcn_arch}") + if self.is_checkpoint_mxfp4_serialized: + raise NotImplementedError("Pre-quantized MXFP4 checkpoints are not yet supported on ROCm.") + return ROCmMxfp4OnlineLinearMethod(self) raise NotImplementedError( "DiffusionMXFP4Config (W4A4 MXFP4) is currently only supported " - "on NPU (Ascend) platforms. CUDA support is not yet implemented." + "on NPU (Ascend) and ROCm (AMD, gfx950) platforms." ) return None @@ -332,6 +341,151 @@ class NPUMxfp4OnlineLinearMethod(_LazyWeightMixin, NPUMxfp4LinearMethod): layer._already_called_process_weights_after_loading = True +# --------------------------------------------------------------------------- +# ROCm MXFP4 base method (AITER) — used as base class for the online subclass. +# Online-only for now; offline (pre-quantized checkpoint) not yet supported. +# --------------------------------------------------------------------------- + + +def _register_rocm_mxfp4_op() -> None: + """Register the vllm_omni::rocm_mxfp4_gemm custom op for torch.compile. + + Wraps activation quantization + GEMM in a single opaque op so that + torch.compile/inductor doesn't try to trace through AITER internals. + """ + import aiter + + @torch.library.custom_op("vllm_omni::rocm_mxfp4_gemm", mutates_args=()) + def _rocm_mxfp4_gemm( + a: torch.Tensor, + w_quant: torch.Tensor, + w_scale: torch.Tensor, + bias: torch.Tensor | None = None, + ) -> torch.Tensor: + quant_func = aiter.get_hip_quant(aiter.QuantType.per_1x32) + a_quant, a_scale = quant_func(a, shuffle=True) + return aiter.gemm_a4w4( + a_quant, + w_quant, + a_scale, + w_scale, + bpreshuffle=True, + bias=bias, + ) + + @_rocm_mxfp4_gemm.register_fake + def _rocm_mxfp4_gemm_fake( + a: torch.Tensor, + w_quant: torch.Tensor, + w_scale: torch.Tensor, + bias: torch.Tensor | None = None, + ) -> torch.Tensor: + M, _ = a.shape + N, _ = w_quant.shape + return torch.empty(M, N, dtype=a.dtype, device=a.device) + + +class ROCmMxfp4LinearMethod(MXFPLinearMethodBase): + """ROCm W4A4 MXFP4 linear method using AITER (gemm_a4w4). + + Weight buffers after process_weights_after_loading: + weight_shuffle : FP4 quantized + shuffled via shuffle_weight(layout=(16,16)) + weight_scale : per-group-of-32 scales from AITER per_1x32 + + Forward path: + _quantize_activation: pass-through (activation quant is inside the custom op) + _quant_matmul: torch.ops.vllm_omni.rocm_mxfp4_gemm wraps activation quant + then gemm_a4w4 in one custom op + """ + + def __init__(self, quant_config: DiffusionMXFP4Config) -> None: + self.quant_config = quant_config + self.out_dtype = torch.get_default_dtype() + if not hasattr(torch.ops.vllm_omni, "rocm_mxfp4_gemm"): + _register_rocm_mxfp4_op() + + def process_weights_after_loading(self, layer: Module) -> None: + if getattr(layer, "_already_called_process_weights_after_loading", False): + return + + import aiter + from aiter.ops.shuffle import shuffle_weight + + quant_func = aiter.get_hip_quant(aiter.QuantType.per_1x32) + weight_quant, weight_scale = quant_func(layer.weight.data, shuffle=True) + weight_shuffled = shuffle_weight(weight_quant, layout=(16, 16)) + + # Store quantized tensors as non-parameter buffers; delete original weight. + layer.register_buffer("weight_shuffle", weight_shuffled, persistent=True) + layer.register_buffer("weight_scale", weight_scale, persistent=True) + + # Remove the original weight parameter to free memory. + if hasattr(layer, "weight") and isinstance(layer.weight, torch.nn.Parameter): + delattr(layer, "weight") + layer.register_parameter("weight", None) + + layer._already_called_process_weights_after_loading = True + + def _quantize_activation(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + # Activation quantization is inside the custom op called by _quant_matmul, + # so pass through the raw activation here. + return x, None + + def _quant_matmul( + self, + x_q: torch.Tensor, + x_scale: torch.Tensor, + layer: torch.nn.Module, + bias: torch.Tensor | None, + ori_dtype: torch.dtype, + ) -> torch.Tensor: + output = torch.ops.vllm_omni.rocm_mxfp4_gemm( + x_q, + layer.weight_shuffle, + layer.weight_scale, + bias, + ) + if output.dtype != ori_dtype: + output = output.to(ori_dtype) + return output + + +# --------------------------------------------------------------------------- +# ROCm MXFP4 online method (BF16 checkpoint → quantize at load time) +# --------------------------------------------------------------------------- + + +class ROCmMxfp4OnlineLinearMethod(_LazyWeightMixin, ROCmMxfp4LinearMethod): + """ROCm W4A4 MXFP4 online linear method using AITER. + + MRO: ROCmMxfp4OnlineLinearMethod → _LazyWeightMixin → ROCmMxfp4LinearMethod + → MXFPLinearMethodBase → LinearMethodBase + + create_weights : _LazyWeightMixin (meta device + patched loader) + process_weights : ROCmMxfp4OnlineLinearMethod (meta → materialize, then AITER quant + shuffle) + apply / ops : ROCmMxfp4LinearMethod / MXFPLinearMethodBase (shared) + """ + + def process_weights_after_loading(self, layer: Module) -> None: + if getattr(layer, "_already_called_process_weights_after_loading", False): + return + + # Materialise from meta device if needed (same pattern as NPU online). + if layer.weight is not None and layer.weight.device == torch.device("meta"): + weight = ModelWeightParameter( + data=torch.empty_like(layer.weight, device=layer._load_device), + input_dim=1, + output_dim=0, + weight_loader=layer.weight.weight_loader, + ) + _copy_missing_attrs(layer.weight, weight) + layer.register_parameter("weight", weight) + initialize_single_dummy_weight(layer.weight) + + # Delegate to the base class which does AITER quant + shuffle. + ROCmMxfp4LinearMethod.process_weights_after_loading(self, layer) + + # --------------------------------------------------------------------------- # NPU MXFP4 dual-scale offline method (W4A4_MXFP4_DUALSCALE checkpoint) # --------------------------------------------------------------------------- From d8db832fc2aaab200cdb9f34a59a1a6a35361813 Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Fri, 3 Jul 2026 15:01:25 +0800 Subject: [PATCH 013/101] [NPU] Upgrade to v0.24.0 (#4848) Signed-off-by: Weiming Liao --- docker/Dockerfile.npu | 2 +- docker/Dockerfile.npu.a3 | 2 +- docker/Dockerfile.npu.ci | 2 +- docker/Dockerfile.npu.ci.a3 | 2 +- .../installation/npu/npu.inc.md | 41 ++++++++++--------- .../npu/worker/npu_ar_model_runner.py | 8 +++- .../npu/worker/npu_generation_model_runner.py | 15 +++++-- .../platforms/npu/worker/npu_model_runner.py | 7 +++- 8 files changed, 49 insertions(+), 30 deletions(-) diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu index 95774dd25..29ea95cc3 100644 --- a/docker/Dockerfile.npu +++ b/docker/Dockerfile.npu @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e +ARG VLLM_ASCEND_TAG=v0.24.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm diff --git a/docker/Dockerfile.npu.a3 b/docker/Dockerfile.npu.a3 index ea8882a38..90036d345 100644 --- a/docker/Dockerfile.npu.a3 +++ b/docker/Dockerfile.npu.a3 @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3 +ARG VLLM_ASCEND_TAG=v0.24.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci index 2c027992e..f8746d2c8 100644 --- a/docker/Dockerfile.npu.ci +++ b/docker/Dockerfile.npu.ci @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e +ARG VLLM_ASCEND_TAG=v0.24.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni diff --git a/docker/Dockerfile.npu.ci.a3 b/docker/Dockerfile.npu.ci.a3 index 4e0de0a1d..5e5848d39 100644 --- a/docker/Dockerfile.npu.ci.a3 +++ b/docker/Dockerfile.npu.ci.a3 @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.23.0-4885d8e-a3 +ARG VLLM_ASCEND_TAG=v0.24.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni diff --git a/docs/getting_started/installation/npu/npu.inc.md b/docs/getting_started/installation/npu/npu.inc.md index c5b13dd73..87d479b49 100644 --- a/docs/getting_started/installation/npu/npu.inc.md +++ b/docs/getting_started/installation/npu/npu.inc.md @@ -1,19 +1,16 @@ # --8<-- [start:requirements] -For detailed hardware and software requirements, please refer to the [vllm-ascend installation documentation](https://docs.vllm.ai/projects/ascend/en/latest/installation.html). +For detailed hardware and software requirements, please refer to the [vLLM-Ascend installation documentation](https://docs.vllm.ai/projects/ascend/en/latest/installation.html). # --8<-- [end:requirements] # --8<-- [start:installation-release] -The recommended way to use vLLM-Omni on NPU is through the vllm-ascend pre-built Docker images: +The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built Docker images: ```bash -# Update the vllm-ascend image -# Atlas A2: -# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1 -# Atlas A3: -# export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1-a3 -export IMAGE=quay.io/ascend/vllm-ascend:v0.18.0rc1 +# vLLM-Ascend image +# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.24.0 +# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.24.0-a3 docker run --rm \ --name vllm-omni-npu \ --shm-size=1g \ @@ -31,11 +28,11 @@ docker run --rm \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v /root/.cache:/root/.cache \ -p 8000:8000 \ - -it $IMAGE bash + -it quay.io/atlas-ci/vllm-ascend:0.24.0-a3 bash # Inside the container, install vLLM-Omni from source cd /vllm-workspace -git clone -b v0.18.0 https://github.com/vllm-project/vllm-omni.git +git clone -b v0.24.0 https://github.com/vllm-project/vllm-omni.git cd vllm-omni pip install -v -e . --no-build-isolation # or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e . @@ -87,14 +84,11 @@ Supported images as following. Here's an example deployment command that has been verified on 4 x NPUs: ```bash -# Atlas A2: -# export IMAGE=quay.io/ascend/vllm-omni:v0.18.0 -# Atlas A3: -# export IMAGE=quay.io/ascend/vllm-omni:v0.18.0-a3 -export IMAGE=quay.io/ascend/vllm-omni:v0.18.0 +# Atlas A2: quay.io/ascend/vllm-omni:v0.24.0 +# Atlas A3: quay.io/ascend/vllm-omni:v0.24.0-a3 docker run --rm \ - --name vllm-omni-npu \ - --shm-size=1g \ + --name vllm-omni-a3 \ + --shm-size=4g \ --device /dev/davinci0 \ --device /dev/davinci1 \ --device /dev/davinci2 \ @@ -106,10 +100,17 @@ docker run --rm \ -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ + --device /dev/davinci_manager \ + --device /dev/devmm_svm \ + --device /dev/hisi_hdc \ + -v /usr/local/dcmi:/usr/local/dcmi \ + -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ + -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ + -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ -v /etc/ascend_install.info:/etc/ascend_install.info \ - -v /root/.cache:/root/.cache \ - -p 8000:8000 \ - -it $IMAGE bash + -v ~/.cache:/root/.cache \ + -p 8091:8091 \ + -it quay.io/ascend/vllm-omni:v0.24.0-a3 bash ``` !!! tip diff --git a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py index c66d493c2..b469234c1 100644 --- a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py @@ -479,7 +479,6 @@ class NPUARModelRunner(OmniNPUModelRunner): logits_indices, spec_decode_metadata, total_num_scheduled_tokens, - num_scheduled_tokens_compressed_list, ) = self._prepare_inputs( scheduler_output, num_scheduled_tokens_np, @@ -576,7 +575,12 @@ class NPUARModelRunner(OmniNPUModelRunner): # Another possible condition is num_tokens_padded != num_tokens_unpadded # but this scope is way too big and the consequences are unpredictable num_reqs_padded = self._pad_query_start_loc_for_fia( - num_tokens_padded, num_reqs_padded, num_reqs, cudagraph_mode, batch_desc.num_reqs + self.query_start_loc, + num_tokens_padded, + num_reqs_padded, + num_reqs, + cudagraph_mode, + batch_desc.num_reqs, ) (attn_metadata, spec_decode_common_attn_metadata) = self._build_attention_metadata( diff --git a/vllm_omni/platforms/npu/worker/npu_generation_model_runner.py b/vllm_omni/platforms/npu/worker/npu_generation_model_runner.py index df8b92bf5..ea03842b4 100644 --- a/vllm_omni/platforms/npu/worker/npu_generation_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_generation_model_runner.py @@ -164,7 +164,6 @@ class NPUGenerationModelRunner(OmniNPUModelRunner): logits_indices, spec_decode_metadata, total_num_scheduled_tokens, - num_scheduled_tokens_compressed_list, ) = self._prepare_inputs( scheduler_output, num_scheduled_tokens_np, @@ -261,7 +260,12 @@ class NPUGenerationModelRunner(OmniNPUModelRunner): # Another possible condition is num_tokens_padded != num_tokens_unpadded # but this scope is way too big and the consequences are unpredictable num_reqs_padded = self._pad_query_start_loc_for_fia( - num_tokens_padded, num_reqs_padded, num_reqs, cudagraph_mode, batch_desc.num_reqs + self.query_start_loc, + num_tokens_padded, + num_reqs_padded, + num_reqs, + cudagraph_mode, + batch_desc.num_reqs, ) (attn_metadata, spec_decode_common_attn_metadata) = self._build_attention_metadata( @@ -729,7 +733,12 @@ class NPUGenerationModelRunner(OmniNPUModelRunner): if not profile_cpp: num_reqs_padded = self._pad_query_start_loc_for_fia( - num_tokens_padded, num_reqs_padded, num_reqs, cudagraph_runtime_mode, batch_desc.num_reqs + self.query_start_loc, + num_tokens_padded, + num_reqs_padded, + num_reqs, + cudagraph_runtime_mode, + batch_desc.num_reqs, ) pad_attn = cudagraph_runtime_mode == CUDAGraphMode.FULL diff --git a/vllm_omni/platforms/npu/worker/npu_model_runner.py b/vllm_omni/platforms/npu/worker/npu_model_runner.py index 3b9b4937a..671c7b7e5 100644 --- a/vllm_omni/platforms/npu/worker/npu_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_model_runner.py @@ -193,7 +193,12 @@ class OmniNPUModelRunner(OmniGPUModelRunner, NPUModelRunner): if not profile_cpp: num_reqs_padded = self._pad_query_start_loc_for_fia( - num_tokens_padded, num_reqs_padded, num_reqs, cudagraph_runtime_mode, batch_desc.num_reqs + self.query_start_loc, + num_tokens_padded, + num_reqs_padded, + num_reqs, + cudagraph_runtime_mode, + batch_desc.num_reqs, ) pad_attn = cudagraph_runtime_mode == CUDAGraphMode.FULL From a8b2bf11d2f2e9282e2182c2aad932d58777e3dc Mon Sep 17 00:00:00 2001 From: Canlin Guo Date: Fri, 3 Jul 2026 16:00:30 +0800 Subject: [PATCH 014/101] [Feature] Support MOSS-TTS-Local-v1.5 Streaming (#4804) Signed-off-by: gcanlin --- tests/worker/test_omni_gpu_model_runner.py | 6 +- .../benchmarks/data_modules/seed_tts_eval.py | 70 +- vllm_omni/benchmarks/patch/patch.py | 34 +- vllm_omni/deploy/moss_tts_local.yaml | 6 +- .../entrypoints/openai/serving_speech.py | 15 +- .../models/moss_tts/audio_tokenizer_v2.py | 1959 +++++++++++++++++ .../configuration_moss_audio_tokenizer_v2.py | 359 +++ .../moss_tts/modeling_moss_tts_codec.py | 471 +++- .../moss_tts/modeling_moss_tts_local.py | 3 +- .../moss_tts/modeling_moss_tts_local_depth.py | 21 +- .../moss_tts/modeling_moss_tts_talker.py | 285 ++- .../models/moss_tts/pipeline.py | 11 +- .../stage_input_processors/moss_tts.py | 149 +- .../worker/gpu_generation_model_runner.py | 8 + vllm_omni/worker/gpu_model_runner.py | 14 +- 15 files changed, 3222 insertions(+), 189 deletions(-) create mode 100644 vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py create mode 100644 vllm_omni/model_executor/models/moss_tts/configuration_moss_audio_tokenizer_v2.py diff --git a/tests/worker/test_omni_gpu_model_runner.py b/tests/worker/test_omni_gpu_model_runner.py index f562d18ce..e68dd6c43 100644 --- a/tests/worker/test_omni_gpu_model_runner.py +++ b/tests/worker/test_omni_gpu_model_runner.py @@ -275,7 +275,7 @@ def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker runner = _make_runner(req_ids=("r1",), hidden_size=4) runner.requests["r1"].sampling_params = SimpleNamespace( seed=42, - extra_args={"qwen3_tts_request_seed": 42}, + extra_args={"tts_local_seed": 42}, ) runner.talker_mtp = CaptureTalkerMTP() runner.vllm_config = SimpleNamespace( @@ -319,11 +319,11 @@ def test_talker_mtp_forward_keeps_explicit_seeded_requests_scalar(monkeypatch): runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4) runner.requests["r1"].sampling_params = SimpleNamespace( seed=11, - extra_args={"qwen3_tts_request_seed": 11}, + extra_args={"tts_local_seed": 11}, ) runner.requests["r2"].sampling_params = SimpleNamespace( seed=22, - extra_args={"qwen3_tts_request_seed": 22}, + extra_args={"tts_local_seed": 22}, ) runner.talker_mtp = CaptureTalkerMTP() runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(subtalker_sampling_params={})) diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py index eb06dde70..efa312dec 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py @@ -36,7 +36,9 @@ Enable with ``SEED_TTS_WER_EVAL=1`` or ``--seed-tts-wer-eval``. Install optional pip install 'vllm-omni[dev]' Env: ``SEED_TTS_EVAL_DEVICE`` (e.g. ``cuda:0``, ``cpu``); ``SEED_TTS_HF_WHISPER_MODEL`` -defaults to ``openai/whisper-large-v3`` (override for debugging only). +defaults to ``openai/whisper-large-v3`` (override for debugging only). Set +``SEED_TTS_WER_SAVE_AUDIO_DIR`` to save the captured 24 kHz mono WAV used by +WER evaluation for each synthesized utterance. """ from __future__ import annotations @@ -50,6 +52,7 @@ import string import tempfile import threading import wave +from pathlib import Path from typing import Any import numpy as np @@ -87,6 +90,30 @@ def pcm_s16le_mono_to_wav_bytes(pcm: bytes, *, sample_rate: int = 24000) -> byte return buf.getvalue() +def _safe_filename_part(value: Any, *, default: str = "item", max_len: int = 96) -> str: + text = str(value or "").strip() or default + safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in text) + safe = safe.strip("._") or default + return safe[:max_len] + + +def _save_seed_tts_eval_audio( + pcm: bytes, + *, + output_dir: Path | None, + index: int, + utterance_id: Any, + locale: str, +) -> str | None: + if output_dir is None: + return None + output_dir.mkdir(parents=True, exist_ok=True) + stem = f"{index:05d}_{_safe_filename_part(utterance_id)}_{_safe_filename_part(locale)}" + path = output_dir / f"{stem}.wav" + path.write_bytes(pcm_s16le_mono_to_wav_bytes(pcm, sample_rate=24000)) + return str(path) + + def _get_eval_device() -> str: explicit = os.environ.get("SEED_TTS_EVAL_DEVICE", "").strip() if explicit: @@ -512,13 +539,18 @@ def compute_seed_tts_wer_metrics( sim_skipped_no_ref = 0 utmos_failed = 0 utmos_on = _eval_submetric_enabled("SEED_TTS_UTMOS_EVAL", default=False) + save_audio_raw = os.environ.get("SEED_TTS_WER_SAVE_AUDIO_DIR", "").strip() + save_audio_dir = Path(save_audio_raw).expanduser() if save_audio_raw else None + saved_audio = 0 + save_audio_failed = 0 - for req, out in zip(input_requests, outputs, strict=True): + for index, (req, out) in enumerate(zip(input_requests, outputs, strict=True)): assert isinstance(req, SeedTTSSampleRequest) ref = req.prompt locale = req.seed_tts_locale or "en" row_lang = "zh" if locale.lower().startswith("zh") else "en" utmos_v: float | None = None + audio_path: str | None = None if not out.success: request_failed += 1 @@ -545,6 +577,23 @@ def compute_seed_tts_wer_metrics( } ) continue + try: + audio_path = _save_seed_tts_eval_audio( + pcm, + output_dir=save_audio_dir, + index=index, + utterance_id=req.seed_tts_utterance_id, + locale=locale, + ) + if audio_path: + saved_audio += 1 + except OSError as e: + save_audio_failed += 1 + logger.warning( + "Seed-TTS WER audio save failed for utterance=%s: %s", + req.seed_tts_utterance_id, + e, + ) wav_16k = _pcm_s16le_to_f32_16k(pcm) if len(wav_16k) == 0: @@ -555,6 +604,7 @@ def compute_seed_tts_wer_metrics( "utterance_id": req.seed_tts_utterance_id, "locale": locale, "error": "empty_audio", + "audio_path": audio_path, } ) continue @@ -609,6 +659,7 @@ def compute_seed_tts_wer_metrics( "locale": locale, "error": "asr_exception", "detail": str(e)[:500], + "audio_path": audio_path, } ) continue @@ -621,6 +672,7 @@ def compute_seed_tts_wer_metrics( "utterance_id": req.seed_tts_utterance_id, "locale": locale, "error": "empty_asr", + "audio_path": audio_path, } ) continue @@ -637,6 +689,7 @@ def compute_seed_tts_wer_metrics( "locale": locale, "error": "wer_compute_failed", "detail": str(e)[:500], + "audio_path": audio_path, } ) continue @@ -673,6 +726,8 @@ def compute_seed_tts_wer_metrics( "reference_raw": raw_truth, "asr_raw": raw_hypo, } + if audio_path: + row["audio_path"] = audio_path if sim_v is not None: row["sim"] = sim_v if utmos_v is not None: @@ -697,7 +752,11 @@ def compute_seed_tts_wer_metrics( "seed_tts_utmos_mean": statistics.fmean(utmos_values) if utmos_values else None, "seed_tts_utmos_median": statistics.median(utmos_values) if utmos_values else None, "seed_tts_utmos_failed": utmos_failed, + "seed_tts_saved_audio": saved_audio, + "seed_tts_save_audio_failed": save_audio_failed, } + if save_audio_dir is not None: + result["seed_tts_save_audio_dir"] = str(save_audio_dir) if include_per_item: result["seed_tts_wer_eval_items"] = items return result @@ -729,6 +788,13 @@ def print_seed_tts_wer_summary(metrics: dict[str, Any]) -> None: print("{:<40} {:<10}".format("Request failed:", metrics.get("seed_tts_request_failed", 0))) print("{:<40} {:<10}".format("No PCM captured:", metrics.get("seed_tts_no_pcm", 0))) print("{:<40} {:<10}".format("ASR / WER failed:", metrics.get("seed_tts_asr_failed", 0))) + save_dir = metrics.get("seed_tts_save_audio_dir") + if save_dir: + print("{:<40} {:<10}".format("Saved eval WAVs:", metrics.get("seed_tts_saved_audio", 0))) + print("{:<40} {}".format("Saved eval WAV dir:", save_dir)) + failed = int(metrics.get("seed_tts_save_audio_failed", 0) or 0) + if failed: + print("{:<40} {:<10}".format("Save eval WAV errors:", failed)) if sim_ev or metrics.get("seed_tts_sim_skipped_no_ref") or metrics.get("seed_tts_sim_failed"): print("{:<40} {:<10}".format("SIM evaluated (higher ~ closer):", sim_ev)) sm = metrics.get("seed_tts_sim_mean") diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index a756c97f0..d6e28319c 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -137,6 +137,30 @@ def _audio_pcm_format() -> tuple[int, int]: return sample_rate, channels +def _pcm_s16le_to_seed_tts_wer_bytes( + pcm_bytes: bytes, + *, + sample_rate: int, + channels: int, +) -> bytes: + """Normalize streamed raw PCM to the 24 kHz mono PCM used by Seed-TTS WER.""" + if not pcm_bytes: + return b"" + channels = max(1, int(channels)) + pcm = np.frombuffer(pcm_bytes, dtype=np.int16) + if channels > 1: + usable = (pcm.size // channels) * channels + pcm = pcm[:usable].reshape(-1, channels).mean(axis=1) + pcm_f32 = pcm.astype(np.float32) / 32767.0 + if int(sample_rate) != 24000 and pcm_f32.size: + from vllm.multimodal.audio import AudioResampler + + resampler = AudioResampler(target_sr=24000) + pcm_f32 = resampler.resample(pcm_f32, orig_sr=int(sample_rate)) + pcm_f32 = np.clip(pcm_f32, -1.0, 1.0) + return (pcm_f32 * 32767).astype(np.int16).tobytes() + + get_samples_old = datasets.get_samples _DEFAULT_DAILY_OMNI_REPO = "liarliar/Daily-Omni" @@ -1216,7 +1240,15 @@ async def async_request_openai_audio_speech( output.audio_continuity_ok = continuity.is_continuous output.audio_underrun_event_count = continuity.underrun_event_count if pcm_capture is not None and pcm_capture: - output.tts_output_pcm_bytes = bytes(pcm_capture) + try: + output.tts_output_pcm_bytes = _pcm_s16le_to_seed_tts_wer_bytes( + bytes(pcm_capture), + sample_rate=sample_rate, + channels=channels, + ) + except Exception as ex: + logger.warning("Seed-TTS WER PCM normalization failed: %s", ex) + output.tts_output_pcm_bytes = bytes(pcm_capture) elif capture_wer_pcm: ct = response.headers.get("Content-Type", "") logger.warning( diff --git a/vllm_omni/deploy/moss_tts_local.yaml b/vllm_omni/deploy/moss_tts_local.yaml index 7e474caa6..1455ccc09 100644 --- a/vllm_omni/deploy/moss_tts_local.yaml +++ b/vllm_omni/deploy/moss_tts_local.yaml @@ -14,8 +14,10 @@ connectors: connector_get_sleep_s: 0.005 connector_get_max_wait_first_chunk: 1000 connector_get_max_wait: 300 - codec_chunk_frames: 15 # 15 frames * 80 ms/frame = 1.2 s chunks + initial_codec_chunk_frames: 1 # 1 frame * 80 ms/frame = 80 ms first audio chunk + codec_chunk_frames: 15 # 15 frames * 80 ms/frame = 1.2 s steady chunks codec_left_context_frames: 0 + codec_stream_slots: 8 stages: - stage_id: 0 @@ -37,7 +39,7 @@ stages: repetition_penalty: 1.1 - stage_id: 1 - max_num_seqs: 1 + max_num_seqs: 8 gpu_memory_utilization: 0.15 enforce_eager: true enable_prefix_caching: false diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index ac37a63a2..78bb0ab2c 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -3705,15 +3705,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): 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: - import copy + import copy - sampling_params_list = copy.deepcopy(sampling_params_list) - sampling_params_list[0].seed = request.seed - if self._tts_model_type == "qwen3_tts": - if sampling_params_list[0].extra_args is None: - sampling_params_list[0].extra_args = {} - sampling_params_list[0].extra_args["qwen3_tts_request_seed"] = request.seed + sampling_params_list = copy.deepcopy(sampling_params_list) + stage0_params = sampling_params_list[0] + stage0_params.seed = request.seed + if stage0_params.extra_args is None: + stage0_params.extra_args = {} + stage0_params.extra_args["tts_local_seed"] = request.seed generator = self.engine_client.generate( prompt=prompt, diff --git a/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py b/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py new file mode 100644 index 000000000..f3ba18c36 --- /dev/null +++ b/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py @@ -0,0 +1,1959 @@ +# Copyright 2026 OpenMOSS and the HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""PyTorch MossAudioTokenizer model.""" + +from __future__ import annotations + +import copy +import math +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from typing import cast + +import torch +import torch.nn as nn +import torch.nn.functional as F +from transformers.modeling_utils import PreTrainedAudioTokenizerBase +from transformers.utils import ModelOutput, auto_docstring, logging + +from .configuration_moss_audio_tokenizer_v2 import MossAudioTokenizerConfig + +logger = logging.get_logger(__name__) + + +# ============================================================================= +# Output Classes +# ============================================================================= + + +@dataclass +@auto_docstring +class MossAudioTokenizerEncoderOutput(ModelOutput): + r""" + audio_codes (`torch.LongTensor` of shape `(num_quantizers, batch_size, sequence_length)`, *optional*): + Discrete audio codes computed using the encoder and quantizer. + audio_codes_lengths (`torch.LongTensor` of shape `(batch_size,)`, *optional*): + Valid lengths for each sample's audio codes. + encoder_hidden_states (`torch.FloatTensor` of shape `(batch_size, hidden_size, sequence_length)`, *optional*): + Hidden states from the encoder before quantization. + """ + + audio_codes: torch.Tensor | None = None + audio_codes_lengths: torch.Tensor | None = None + encoder_hidden_states: torch.Tensor | None = None + + +@dataclass +@auto_docstring +class MossAudioTokenizerDecoderOutput(ModelOutput): + r""" + audio (`torch.FloatTensor` of shape `(batch_size, channels, sequence_length)`, *optional*): + Decoded audio waveform. + audio_lengths (`torch.LongTensor` of shape `(batch_size,)`, *optional*): + Valid lengths for each sample's audio. + """ + + audio: torch.Tensor | None = None + audio_lengths: torch.Tensor | None = None + + +@dataclass +@auto_docstring +class MossAudioTokenizerOutput(ModelOutput): + r""" + audio (`torch.FloatTensor` of shape `(batch_size, channels, sequence_length)`, *optional*): + Decoded audio waveform. + audio_lengths (`torch.LongTensor` of shape `(batch_size,)`, *optional*): + Valid lengths for each sample's audio. + audio_codes (`torch.LongTensor` of shape `(num_quantizers, batch_size, sequence_length)`, *optional*): + Discrete audio codes computed using the encoder and quantizer. + audio_codes_lengths (`torch.LongTensor` of shape `(batch_size,)`, *optional*): + Valid lengths for each sample's audio codes. + """ + + audio: torch.Tensor | None = None + audio_lengths: torch.Tensor | None = None + audio_codes: torch.Tensor | None = None + audio_codes_lengths: torch.Tensor | None = None + + +# ============================================================================= +# Streaming Module Base Classes +# ============================================================================= + + +@dataclass +class StreamingState: + """Base state for streaming modules.""" + + batch_size: int + device: torch.device + + def __post_init__(self): + self.exec_mask = torch.ones(self.batch_size, dtype=torch.bool, device=self.device) + + def set_exec_mask(self, exec_mask: torch.Tensor): + self.exec_mask[:] = exec_mask + + def reset(self, reset_mask: torch.Tensor) -> None: + self.exec_mask[:] = torch.where(reset_mask, torch.ones_like(self.exec_mask), self.exec_mask) + + def __enter__(self): + # ExitStack expects a context manager; returning self is conventional and useful for debugging. + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + pass + + +class StreamingModule(nn.Module): + """Base class for streaming components.""" + + def __init__(self) -> None: + super().__init__() + self._streaming_state: StreamingState | None = None + self._streaming_detached: bool = False + self._cached_children: list[tuple[str, StreamingModule]] | None = None + + @property + def is_streaming(self): + return self._streaming_state is not None + + def _apply_named_streaming(self, fn): + def _handle_module(prefix: str, module: nn.Module): + if isinstance(module, StreamingModule): + if module._streaming_detached and prefix != "": + return + if self._cached_children is None: + raise RuntimeError("Internal error: _cached_children should be initialized before traversal.") + self._cached_children.append((prefix, module)) + for name, child in module.named_children(): + new_prefix = f"{prefix}.{name}" if prefix else name + _handle_module(new_prefix, child) + + if self._cached_children is None: + self._cached_children = [] + _handle_module("", self) + for name, child in self._cached_children: + fn(name, child) + + def _start_streaming(self, batch_size: int, exit_stack: ExitStack): + def _start_streaming_fn(name: str, module: StreamingModule): + if module._streaming_state is not None: + raise RuntimeError(f"{name} is already streaming!") + state = module._init_streaming_state(batch_size) + exit_stack.enter_context(state) + module._streaming_state = state + + self._apply_named_streaming(_start_streaming_fn) + + def _stop_streaming(self) -> None: + def _stop_streaming_fn(name: str, module: StreamingModule): + module._streaming_state = None + + self._apply_named_streaming(_stop_streaming_fn) + + def _init_streaming_state(self, batch_size: int) -> StreamingState: + device = next(iter(self.parameters())).device + return StreamingState(batch_size, device) + + def streaming(self, batch_size: int) -> ExitStack: + """Context manager to enter streaming mode.""" + exit_stack = ExitStack() + self._start_streaming(batch_size, exit_stack) + exit_stack.callback(self._stop_streaming) + return exit_stack + + def _set_streaming_exec_mask(self, exec_mask: torch.Tensor) -> None: + """Set the active slot mask on every live streaming module.""" + + def _set_exec_mask(_: str, module: StreamingModule) -> None: + state = module._streaming_state + if state is not None: + state.set_exec_mask(exec_mask.to(state.device)) + + self._apply_named_streaming(_set_exec_mask) + + +class StreamingContainer(StreamingModule): + """Container for streaming modules.""" + + pass + + +# ============================================================================= +# Normalization Layers +# ============================================================================= + + +class MossAudioTokenizerRMSNorm(nn.Module): + """Root Mean Square Layer Normalization.""" + + def __init__( + self, + dim: int, + eps: float = 1e-5, + dtype: torch.dtype | None = None, + device=None, + ): + super().__init__() + self.eps = eps + self.dtype = dtype + self.alpha = nn.Parameter(torch.full((1, 1, dim), 1.0, requires_grad=True, device=device, dtype=dtype)) + + def forward(self, x: torch.Tensor): + x_dtype = x.dtype + if self.dtype is not None: + x = x.to(self.dtype) + var = self.eps + torch.mean(x**2, dim=2, keepdim=True) + y = (x * (self.alpha.to(var) * torch.rsqrt(var))).to(x_dtype) + return y + + +class MossAudioTokenizerLayerScale(nn.Module): + """Layer scale from Touvron et al. 2021.""" + + def __init__( + self, + channels: int, + init: float = 1e-4, + channel_last: bool = True, + device=None, + dtype=None, + ): + super().__init__() + self.channel_last = channel_last + self.scale = nn.Parameter(torch.full((channels,), init, requires_grad=True, device=device, dtype=dtype)) + + def forward(self, x: torch.Tensor): + if self.channel_last: + return self.scale * x + else: + return self.scale[:, None] * x + + +def create_norm_fn(norm_type: str, dim: int, **kwargs) -> nn.Module: + """Create normalization module.""" + if norm_type == "layer_norm": + return nn.LayerNorm(dim, eps=1e-5, **kwargs) + elif norm_type in {"rms_norm"}: + return MossAudioTokenizerRMSNorm(dim, eps=1e-5, **kwargs) + elif norm_type in {"rms_norm_f32"}: + kwargs.pop("dtype", None) + return MossAudioTokenizerRMSNorm(dim, eps=1e-8, dtype=torch.float, **kwargs) + else: + raise ValueError(f"Unknown norm type: {norm_type}") + + +# ============================================================================= +# Rotary Position Embedding +# ============================================================================= + + +def apply_rope( + q: torch.Tensor, + k: torch.Tensor, + offset: torch.Tensor, + max_period: float = 10_000, + time_before_heads: bool = False, +): + """Apply rotary position embedding.""" + if time_before_heads: + B, T, H, D = q.shape + else: + B, H, T, D = q.shape + if k.shape != q.shape: + raise ValueError(f"Expected k.shape == q.shape, got k={tuple(k.shape)} q={tuple(q.shape)}") + if D <= 0 or (D % 2) != 0: + raise ValueError(f"RoPE requires an even last dimension, got D={D}") + + ds = torch.arange(D // 2, device=q.device, dtype=torch.float32) + freqs = torch.exp(ds * (-math.log(max_period) * 2 / D)) + ts = offset.float().view(-1, 1) + torch.arange(T, device=q.device, dtype=torch.float32) + + if time_before_heads: + ts = ts.view(B, -1, 1, 1) + else: + ts = ts.view(B, 1, -1, 1) + + dims = q.shape[:-1] + q = q.view(*dims, D // 2, 2) + k = k.view(*dims, D // 2, 2) + + qr, qi = q[..., 0].float(), q[..., 1].float() + kr, ki = k[..., 0].float(), k[..., 1].float() + + rotr = torch.cos(freqs * ts) + roti = torch.sin(freqs * ts) + + qor = qr * rotr - qi * roti + qoi = qr * roti + qi * rotr + kor = kr * rotr - ki * roti + koi = kr * roti + ki * rotr + + dtype = q.dtype + qo = torch.stack([qor.to(dtype), qoi.to(dtype)], dim=-1) + ko = torch.stack([kor.to(dtype), koi.to(dtype)], dim=-1) + + return qo.view(*dims, D), ko.view(*dims, D) + + +class MossAudioTokenizerRotaryEmbedding(nn.Module): + """Rotary positional embedding (RoPE).""" + + def __init__(self, max_period: float = 10000.0): + super().__init__() + self.max_period = max_period + + def forward( + self, + q: torch.Tensor, + k: torch.Tensor, + offset: torch.Tensor, + time_before_heads: bool = False, + ): + return apply_rope(q, k, offset, self.max_period, time_before_heads) + + +# ============================================================================= +# Gating Modules +# ============================================================================= + + +class MossAudioTokenizerActivationGating(nn.Module): + """Gating FFN layer with activation.""" + + def __init__(self, dim: int, dim_feedforward: int, activation, **factory_kwargs): + super().__init__() + if dim_feedforward == 4 * dim: + hidden = (21 * dim) // 8 + else: + hidden = (2 * dim_feedforward) // 3 + + self.linear_in = nn.Linear(dim, 2 * hidden, bias=False, **factory_kwargs) + self.linear_out = nn.Linear(hidden, dim, bias=False, **factory_kwargs) + self.activation = activation + + def forward(self, x: torch.Tensor): + x = self.linear_in(x) + B, T, _ = x.shape + x = x.view(B, T, 2, -1) + x = self.activation(x[..., 0, :]) * x[..., 1, :] + x = self.linear_out(x) + return x + + +def _get_activation(name: str): + if name in ["sigmoid", "tanh", "relu"]: + return getattr(torch, name) + elif name in ["leaky_relu", "elu", "gelu", "silu", "mish", "softsign"]: + return getattr(F, name) + elif name == "identity": + return nn.Identity() + else: + raise ValueError(f"Unknown activation {name}") + + +def make_gating(name: str, dim: int, dim_feedforward: int, **factory_kwargs) -> nn.Module: + return MossAudioTokenizerActivationGating(dim, dim_feedforward, _get_activation(name), **factory_kwargs) + + +# ============================================================================= +# Positional Embeddings +# ============================================================================= + + +def create_sin_embedding( + positions: torch.Tensor, + dim: int, + max_period: float = 10000, + dtype: torch.dtype = torch.float32, +) -> torch.Tensor: + """Create sinusoidal positional embedding with shape [B, T, C].""" + if dim % 2 != 0: + raise ValueError(f"Sinusoidal embedding requires even dim, got dim={dim}") + half_dim = dim // 2 + if half_dim <= 1: + raise ValueError(f"Sinusoidal embedding requires dim >= 4, got dim={dim}") + positions = positions.to(dtype) + adim = torch.arange(half_dim, device=positions.device, dtype=dtype).view(1, 1, -1) + max_period_tensor = torch.full([], max_period, device=positions.device, dtype=dtype) + phase = positions / (max_period_tensor ** (adim / (half_dim - 1))) + return torch.cat([torch.cos(phase), torch.sin(phase)], dim=-1) + + +# ============================================================================= +# KV Cache for Attention +# ============================================================================= + + +class KVCacheResult: + """Container for KV cache results that supports tuple unpacking.""" + + __slots__ = ("keys", "values", "positions") + + def __init__(self, keys: torch.Tensor, values: torch.Tensor, positions: torch.Tensor): + self.keys = keys + self.values = values + self.positions = positions + + def __iter__(self): + """Allow unpacking as (keys, values, positions).""" + return iter((self.keys, self.values, self.positions)) + + @staticmethod + def from_kv(keys: torch.Tensor, values: torch.Tensor) -> KVCacheResult: + B, H, T, D = keys.shape + positions = torch.arange(T, device=keys.device, dtype=torch.long) + return KVCacheResult(keys, values, positions.expand(B, -1)) + + +class RingKVCache: + """Efficient streaming KVCache compatible with CUDA Graph.""" + + def __init__( + self, + batch_size: int, + num_heads: int, + dim_per_head: int, + capacity: int, + respect_exec_mask: bool = True, + device: torch.device = torch.device("cuda"), + dtype: torch.dtype = torch.bfloat16, + ): + self.capacity = capacity + self.cache = torch.zeros( + (2, batch_size, num_heads, capacity, dim_per_head), + device=device, + dtype=dtype, + ) + self.respect_exec_mask = respect_exec_mask + if self.respect_exec_mask: + self.end_offset = torch.zeros(batch_size, device=device, dtype=torch.long) + else: + self.end_offset = torch.zeros(1, device=device, dtype=torch.long) + + def reset(self, reset_mask: torch.Tensor) -> None: + self.end_offset[:] = torch.where(reset_mask, torch.zeros_like(self.end_offset), self.end_offset) + + def complete(self, k: torch.Tensor, v: torch.Tensor, exec_mask: torch.Tensor) -> KVCacheResult: + B, H, T, D = k.shape + if T <= 0: + raise ValueError(f"Expected T > 0, got T={T}") + + indexes = torch.arange(T, device=self.end_offset.device, dtype=self.end_offset.dtype) + indexes = indexes + self.end_offset.view(-1, 1) + indexes = indexes % self.capacity + + if self.respect_exec_mask: + this_indexes = indexes.view(B, 1, T, 1).expand(-1, H, T, D) + self.cache[0].scatter_(2, this_indexes, k) + self.cache[1].scatter_(2, this_indexes, v) + else: + self.cache[0].index_copy_(2, indexes[0], k) + self.cache[1].index_copy_(2, indexes[0], v) + + keys = self.cache[0] + values = self.cache[1] + + indexes = torch.arange(self.capacity, device=self.end_offset.device, dtype=torch.long) + last_offset = self.end_offset.view(-1, 1) + T - 1 + end_index = last_offset % self.capacity + delta = indexes - end_index + + positions = torch.where( + delta <= 0, + last_offset + delta, + last_offset + delta - self.capacity, + ) + + if self.respect_exec_mask: + self.end_offset[:] = torch.where(exec_mask, self.end_offset + T, self.end_offset) + else: + self.end_offset.add_(T) + + invalid = indexes >= self.end_offset.view(-1, 1) + positions = torch.where(invalid, torch.full_like(positions, -1), positions) + + return KVCacheResult(keys, values, positions) + + +# ============================================================================= +# Multi-Head Attention +# ============================================================================= + + +@dataclass +class MHAState(StreamingState): + kv_cache: RingKVCache | None + offset: torch.Tensor + offset_cpu: int + + def reset(self, reset_mask: torch.Tensor): + super().reset(reset_mask) + self.offset[:] = torch.where(reset_mask, torch.zeros_like(self.offset), self.offset) + if self.kv_cache is not None: + self.kv_cache.reset(reset_mask) + self.offset_cpu = 0 + + +def apply_weights_per_step( + modules: nn.ModuleList, + schedule: list[int] | None, + x: torch.Tensor, + offset: int | None, +) -> torch.Tensor: + """Apply different weights for each time step.""" + if len(modules) == 1: + return modules[0](x) + + if offset is None: + raise ValueError("offset must be provided when using per-step weights (len(modules) > 1).") + ys = [] + B, T, C = x.shape + for t in range(T): + module_index = t + offset + if schedule is not None: + if module_index >= len(schedule) or module_index < 0: + raise ValueError( + f"weights_per_step_schedule is too short for module_index={module_index} (len={len(schedule)})." + ) + module_index = schedule[module_index] + if module_index >= len(modules) or module_index < 0: + raise ValueError(f"module_index={module_index} out of range for len(modules)={len(modules)}.") + y = modules[module_index](x[:, t : t + 1]) + ys.append(y) + return torch.cat(ys, 1) + + +class MossAudioTokenizerMultiheadAttention(StreamingModule): + """Multi-head attention with streaming support.""" + + def __init__( + self, + embed_dim: int, + num_heads: int, + causal: bool = False, + context: int | None = None, + rope: MossAudioTokenizerRotaryEmbedding | None = None, + weights_per_step: int = 0, + weights_per_step_schedule: list[int] | None = None, + device=None, + dtype=None, + ): + super().__init__() + factory_kwargs = {"device": device, "dtype": dtype} + + self.embed_dim = embed_dim + self.causal = causal + self.context = context + self.rope = rope + self.num_heads = num_heads + self.weights_per_step = weights_per_step + self.weights_per_step_schedule = weights_per_step_schedule + + out_dim = 3 * embed_dim + mult = 1 + if weights_per_step: + mult = max(weights_per_step_schedule) + 1 if weights_per_step_schedule else weights_per_step + self.mult = mult + + self.out_projs = nn.ModuleList( + [nn.Linear(embed_dim, embed_dim, bias=False, **factory_kwargs) for _ in range(mult)] + ) + self.in_projs = nn.ModuleList( + [nn.Linear(embed_dim, out_dim, bias=False, **factory_kwargs) for _ in range(mult)] + ) + + self._register_load_state_dict_pre_hook(self._load_hook, with_module=True) + + @staticmethod + def _load_hook(module, state_dict, prefix, *_): + mappings = { + "in_proj_weight": "in_projs.{i}.weight", + "in_proj.weight": "in_projs.{i}.weight", + "out_proj.weight": "out_projs.{i}.weight", + } + mult = module.mult + for suffix in ["", "_scb"]: + for source, target in mappings.items(): + this_source = prefix + source + suffix + if this_source in state_dict: + weight = state_dict[this_source] + _, *OD = weight.shape + weight = weight.view(mult, -1, *OD) + for i in range(mult): + state_dict[prefix + target.format(i=i) + suffix] = weight[i] + state_dict.pop(this_source) + + def _init_streaming_state(self, batch_size: int) -> MHAState: + in_proj = cast(nn.Linear, self.in_projs[0]) + device = cast(torch.device, in_proj.weight.device) + dtype = cast(torch.dtype, in_proj.weight.dtype) + + dim_per_head = self.embed_dim // self.num_heads + if self.context is None: + capacity = self.weights_per_step if self.weights_per_step else 1024 + else: + capacity = self.context + + kv_cache = RingKVCache( + batch_size, + self.num_heads, + dim_per_head, + capacity, + respect_exec_mask=not self.weights_per_step, + device=cast(torch.device, device), + dtype=cast(torch.dtype, dtype), + ) + return MHAState( + batch_size, + cast(torch.device, device), + kv_cache, + offset=torch.zeros(batch_size, device=cast(torch.device, device), dtype=torch.long), + offset_cpu=0, + ) + + def _complete_kv(self, k, v) -> KVCacheResult: + state = cast(MHAState | None, self._streaming_state) + if state is None: + return KVCacheResult.from_kv(k, v) + if state.kv_cache is None: + return KVCacheResult.from_kv(k, v) + return state.kv_cache.complete(k, v, state.exec_mask) + + def forward(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor): + state = cast(MHAState | None, self._streaming_state) + B, T = query.shape[:2] + + if state is None: + offset = torch.zeros(B, device=query.device, dtype=torch.long) + offset_cpu = 0 + else: + offset = state.offset + offset_cpu = state.offset_cpu + + projected = apply_weights_per_step(self.in_projs, self.weights_per_step_schedule, query, offset_cpu) + dim_per_head = self.embed_dim // self.num_heads + projected = projected.reshape(B, T, 3, self.num_heads, dim_per_head).permute(2, 0, 3, 1, 4) + q, k, v = projected[0], projected[1], projected[2] + + if self.rope: + q, k = self.rope(q, k, offset, time_before_heads=False) + + k, v, pos_k = self._complete_kv(k, v) + pos_k = pos_k[:, None] + + if self.causal: + pos_q = offset.view(-1, 1, 1) + torch.arange(T, device=q.device, dtype=torch.long).view(-1, 1) + delta = pos_q - pos_k + attn_bias = (pos_k >= 0) & (delta >= 0) + if self.context is not None: + attn_bias = attn_bias & (delta < self.context) + attn_bias = attn_bias[:, None] + else: + attn_bias = None + + x = F.scaled_dot_product_attention(q, k, v, attn_bias, dropout_p=0.0) + x = x.transpose(1, 2).reshape(B, T, self.embed_dim) + x = apply_weights_per_step(self.out_projs, self.weights_per_step_schedule, x, offset_cpu) + + if state is not None: + state.offset[:] = torch.where(state.exec_mask, state.offset + T, state.offset) + state.offset_cpu += T + return x + + +# ============================================================================= +# Transformer Layer +# ============================================================================= + + +@dataclass +class LayerState(StreamingState): + offset_cpu: int = 0 + + def reset(self, reset_mask: torch.Tensor): + super().reset(reset_mask) + self.offset_cpu = 0 + + +class MossAudioTokenizerTransformerLayer(StreamingModule): + """Transformer layer with streaming support.""" + + def __init__( + self, + d_model: int, + num_heads: int, + dim_feedforward: int = 2048, + causal: bool = False, + context: int | None = None, + rope: MossAudioTokenizerRotaryEmbedding | None = None, + norm: str = "layer_norm", + layer_scale: float | None = None, + gating: str = "none", + weights_per_step: int = 0, + weights_per_step_schedule: list[int] | None = None, + activation=F.gelu, + device=None, + dtype=None, + ): + super().__init__() + factory_kwargs = {"device": device, "dtype": dtype} + + self.self_attn = MossAudioTokenizerMultiheadAttention( + embed_dim=d_model, + num_heads=num_heads, + causal=causal, + context=context, + rope=rope, + weights_per_step=weights_per_step, + weights_per_step_schedule=weights_per_step_schedule, + **factory_kwargs, + ) + self.norm1 = create_norm_fn(norm, d_model, **factory_kwargs) + self.norm2 = create_norm_fn(norm, d_model, **factory_kwargs) + + self.weights_per_step = weights_per_step + self.weights_per_step_schedule = weights_per_step_schedule + self.gating: nn.Module | nn.ModuleList | None = None + self.linear1: nn.Module | None = None + self.linear2: nn.Module | None = None + self.activation = activation + + num_weights = 1 + if weights_per_step: + num_weights = max(weights_per_step_schedule) + 1 if weights_per_step_schedule else weights_per_step + + if gating == "none": + self.linear1 = nn.Linear(d_model, dim_feedforward, bias=False, **factory_kwargs) + self.linear2 = nn.Linear(dim_feedforward, d_model, bias=False, **factory_kwargs) + else: + if weights_per_step: + dim_ff_list = [dim_feedforward] * num_weights if isinstance(dim_feedforward, int) else dim_feedforward + self.gating = nn.ModuleList( + [make_gating(gating, d_model, dim, **factory_kwargs) for dim in dim_ff_list] + ) + else: + self.gating = make_gating(gating, d_model, dim_feedforward, **factory_kwargs) + + if layer_scale is None: + self.layer_scale_1 = nn.Identity() + self.layer_scale_2 = nn.Identity() + else: + self.layer_scale_1 = MossAudioTokenizerLayerScale( + channels=d_model, init=layer_scale, channel_last=True, **cast(dict[str, object], factory_kwargs) + ) + self.layer_scale_2 = MossAudioTokenizerLayerScale( + channels=d_model, init=layer_scale, channel_last=True, **cast(dict[str, object], factory_kwargs) + ) + + def _init_streaming_state(self, batch_size: int) -> LayerState: + device = next(iter(self.parameters())).device + return LayerState(batch_size, device, offset_cpu=0) + + def _ff_block(self, x: torch.Tensor) -> torch.Tensor: + state = self._streaming_state + offset = state.offset_cpu if isinstance(state, LayerState) else 0 + + x_orig = x + x = self.norm2(x) + + if self.gating is None: + assert self.linear1 is not None + assert self.linear2 is not None + update = self.linear2(self.activation(self.linear1(x))) + else: + if self.weights_per_step: + assert isinstance(self.gating, nn.ModuleList) + update = apply_weights_per_step(self.gating, self.weights_per_step_schedule, x, offset) + else: + update = self.gating(x) + return x_orig.to(update) + self.layer_scale_2(update) + + def _sa_block(self, x: torch.Tensor): + x_orig = x + x = self.norm1(x) + update = self.self_attn(x, x, x) + return x_orig.to(update) + self.layer_scale_1(update) + + def forward(self, x: torch.Tensor): + x = self._sa_block(x) + x = self._ff_block(x) + state = self._streaming_state + if state is not None: + assert isinstance(state, LayerState) + state.offset_cpu += x.shape[1] + return x + + +# ============================================================================= +# Streaming Transformer +# ============================================================================= + + +@dataclass +class TransformerState(StreamingState): + offsets: torch.Tensor + + def reset(self, reset_mask: torch.Tensor): + super().reset(reset_mask) + self.offsets[:] = torch.where(reset_mask, torch.zeros_like(self.offsets), self.offsets) + + +class MossAudioTokenizerTransformer(StreamingModule): + """Transformer with streaming/causal support.""" + + def __init__( + self, + d_model: int, + num_heads: int, + num_layers: int, + dim_feedforward: int = 2048, + causal: bool = False, + context: int | None = None, + positional_embedding: str = "sin", + max_period: float = 10_000, + positional_scale: float = 1.0, + device=None, + dtype=None, + **kwargs, + ): + super().__init__() + if d_model % num_heads != 0: + raise ValueError(f"d_model must be divisible by num_heads, got d_model={d_model}, num_heads={num_heads}") + + self.positional_embedding = positional_embedding + self.max_period = max_period + self.positional_scale = positional_scale + + self.rope: MossAudioTokenizerRotaryEmbedding | None = None + if positional_embedding in {"rope", "sin_rope"}: + self.rope = MossAudioTokenizerRotaryEmbedding(max_period=max_period) + + self.layers = nn.ModuleList() + for _ in range(num_layers): + self.layers.append( + MossAudioTokenizerTransformerLayer( + d_model=d_model, + num_heads=num_heads, + dim_feedforward=dim_feedforward, + causal=causal, + context=context, + rope=self.rope, + device=device, + dtype=dtype, + **kwargs, + ) + ) + + def _init_streaming_state(self, batch_size: int) -> TransformerState: + device = next(self.parameters()).device + return TransformerState( + batch_size, + device, + offsets=torch.zeros(batch_size, device=device, dtype=torch.long), + ) + + def forward(self, x: torch.Tensor, *args, **kwargs): + B, T, C = x.shape + state = self._streaming_state + offsets = ( + torch.zeros(1, dtype=torch.long, device=x.device) + if state is None + else ( + state.offsets + if isinstance(state, TransformerState) + else torch.zeros(1, dtype=torch.long, device=x.device) + ) + ) + + if self.positional_embedding in {"sin", "sin_rope"}: + positions = torch.arange(T, device=x.device).view(1, -1, 1) + positions = positions + offsets.view(-1, 1, 1) + pos_emb = create_sin_embedding(positions, C, max_period=self.max_period, dtype=x.dtype) + x = x + self.positional_scale * pos_emb + + for layer in self.layers: + x = layer(x, *args, **kwargs) + + if state is not None: + assert isinstance(state, TransformerState) + state.offsets[:] = torch.where(state.exec_mask, state.offsets + T, state.offsets) + return x + + +class MossAudioTokenizerProjectedTransformer(StreamingContainer): + """Transformer with input/output projections.""" + + def __init__( + self, + input_dimension: int, + output_dimension: int, + d_model: int, + *, + conv_layout: bool = False, + module_type: str, + **kwargs, + ): + super().__init__() + self.module_type = module_type + self.downsample_ratio: int = 1 + self.input_dimension = input_dimension + self.output_dimension = output_dimension + + self.input_proj = nn.Linear(input_dimension, d_model, bias=False) + self.transformer = MossAudioTokenizerTransformer(d_model=d_model, **kwargs) + self.conv_layout = conv_layout + self.output_proj = nn.Linear(d_model, output_dimension, bias=False) + + def forward(self, x, input_lengths, *args, **kwargs): + x = self.input_proj(x.transpose(1, 2)) # (B, D, T) -> (B, T, D) + x = self.transformer(x, *args, **kwargs) + x = self.output_proj(x).transpose(1, 2) # (B, T, D) -> (B, D, T) + return x, input_lengths + + +# ============================================================================= +# Patched Pretransform Module +# ============================================================================= + + +class MossAudioTokenizerPatchedPretransform(nn.Module): + """Patching module for downsampling/upsampling.""" + + def __init__(self, patch_size: int, is_downsample: bool, module_type: str, **kwargs): + super().__init__() + self.patch_size = patch_size + self.downsample_ratio: int = patch_size + self.is_downsample = is_downsample + self.module_type = module_type + + def encode(self, x, input_lengths): + b, d, _ = x.shape + h = self.patch_size + x = x.reshape(b, d, -1, h).permute(0, 1, 3, 2).reshape(b, d * h, -1) + # We pad the input waveform to a multiple of `downsample_rate` before applying the encoder. + # Use a ceil division to match that padding and avoid dropping the last (partially padded) frame. + output_lengths = input_lengths // self.patch_size + return x, output_lengths + + def decode(self, x, input_lengths): + b, dh, length = x.shape + h = self.patch_size + d = dh // h + x = x.reshape(b, d, h, length).permute(0, 1, 3, 2).reshape(b, d, length * h) + output_lengths = input_lengths * self.patch_size + return x, output_lengths + + def forward(self, x, input_lengths): + if self.is_downsample: + return self.encode(x, input_lengths) + else: + return self.decode(x, input_lengths) + + +# ============================================================================= +# Vector Quantization +# ============================================================================= + + +def WNConv1d(*args, **kwargs): + """Weight-normalized Conv1d.""" + return nn.utils.parametrizations.weight_norm(nn.Conv1d(*args, **kwargs)) + + +class MossAudioTokenizerVectorQuantize(nn.Module): + """Single codebook vector quantization (inference only).""" + + def __init__( + self, + input_dim: int, + codebook_size: int, + codebook_dim: int, + **kwargs, + ): + super().__init__() + self.input_dim = input_dim + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + if input_dim != codebook_dim: + self.in_proj = WNConv1d(input_dim, codebook_dim, kernel_size=1) + self.out_proj = WNConv1d(codebook_dim, input_dim, kernel_size=1) + else: + self.in_proj = nn.Identity() + self.out_proj = nn.Identity() + + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + @torch.no_grad() + def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Args: + z: Input tensor of shape (B, D, T) + Returns: + z_q: Quantized tensor of shape (B, D, T) + indices: Code indices of shape (B, T) + z_e: Encoded tensor before quantization + """ + z = z.float() + z_e = self.in_proj(z).float() + + encodings = z_e.transpose(1, 2).reshape(-1, z_e.shape[1]) + + codebook_weight = self.codebook.weight + dist = ( + encodings.pow(2).sum(1, keepdim=True) + - 2 * encodings @ codebook_weight.float().t() + + codebook_weight.float().pow(2).sum(1, keepdim=True).t() + ) + + indices = (-dist).max(1)[1] + indices = indices.reshape(z.size(0), -1) + + z_q = self.decode_code(indices) + z_q = self.out_proj(z_q).float() + + return z_q, indices, z_e + + def decode_code(self, embed_id: torch.Tensor) -> torch.Tensor: + """Decode code indices to embeddings.""" + return self.codebook(embed_id).transpose(1, 2).float() + + +class MossAudioTokenizerLFQ(nn.Module): + """LFQ (inference-only) used by ResidualLFQ.""" + + def __init__( + self, + input_dim: int, + codebook_size: int, + codebook_dim: int, + **kwargs, + ): + super().__init__() + self.input_dim = input_dim + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + if self.input_dim != self.codebook_dim: + self.in_proj = WNConv1d(self.input_dim, self.codebook_dim, kernel_size=1) + self.out_proj = WNConv1d(self.codebook_dim, self.input_dim, kernel_size=1) + else: + self.in_proj = nn.Identity() + self.out_proj = nn.Identity() + + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + @torch.no_grad() + def forward(self, z: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Quantize z into codebook vectors.""" + z = z.float() + z_e = self.in_proj(z).float() + z_q, indices = self.decode_latents(z_e) + z_q = (z_e + (z_q - z_e).detach()).float() + z_q = self.out_proj(z_q).float() + return z_q, indices, z_e + + def embed_code(self, embed_id: torch.Tensor) -> torch.Tensor: + return F.embedding(embed_id, self.codebook.weight) + + def decode_code_wo_out_proj(self, embed_id: torch.Tensor) -> torch.Tensor: + return self.embed_code(embed_id).transpose(1, 2) + + def decode_code(self, embed_id: torch.Tensor) -> torch.Tensor: + z_q = self.decode_code_wo_out_proj(embed_id).float() + z_q = self.out_proj(z_q).float() + return z_q + + def decode_latents(self, latents: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + """Match training LFQ: L2-normalize then argmin squared distance.""" + encodings = latents.transpose(1, 2).reshape(-1, latents.shape[1]).float() + codebook = self.codebook.weight.float() + + encodings = F.normalize(encodings) + codebook = F.normalize(codebook) + + dist = ( + encodings.pow(2).sum(1, keepdim=True) + - 2 * encodings @ codebook.t() + + codebook.pow(2).sum(1, keepdim=True).t() + ) + indices = (-dist).max(1)[1] + indices = indices.reshape(latents.size(0), -1) + z_q = self.decode_code_wo_out_proj(indices).float() + return z_q, indices + + +class MossAudioTokenizerResidualVQ(nn.Module): + """Residual Vector Quantization (inference only).""" + + def __init__( + self, + input_dim: int = 1024, + rvq_dim: int | None = None, + output_dim: int | None = None, + num_quantizers: int = 32, + codebook_size: int = 1024, + codebook_dim: int = 8, + **kwargs, + ): + super().__init__() + self.input_dim = input_dim + self.rvq_dim = rvq_dim or input_dim + self.output_dim = output_dim or input_dim + self.num_quantizers = num_quantizers + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + self.input_proj = ( + WNConv1d(input_dim, self.rvq_dim, kernel_size=1) if input_dim != self.rvq_dim else nn.Identity() + ) + self.output_proj = ( + WNConv1d(self.rvq_dim, self.output_dim, kernel_size=1) if self.rvq_dim != self.output_dim else nn.Identity() + ) + + self.quantizers = nn.ModuleList( + [ + MossAudioTokenizerVectorQuantize( + input_dim=self.rvq_dim, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + **kwargs, + ) + for _ in range(num_quantizers) + ] + ) + + @torch.no_grad() + def forward( + self, + z: torch.Tensor, + input_length: torch.Tensor, + n_quantizers: int | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Args: + z: Input tensor of shape (B, D, T) + input_length: Valid lengths for each sample (B,) + n_quantizers: Number of quantizers to use + Returns: + quantized_out: Quantized output (B, D, T) + all_indices: All code indices (N, B, T) + output_length: Output lengths (B,) + """ + z = self.input_proj(z) + + batch_size, _, max_time = z.shape + mask = torch.arange(max_time, device=z.device).expand(batch_size, max_time) < input_length.unsqueeze(1) + + quantized_out = torch.zeros_like(z, dtype=torch.float32) + residual = z.clone().float() + all_indices = [] + + n_quantizers = n_quantizers or self.num_quantizers + + for i, quantizer in enumerate(self.quantizers): + if i >= n_quantizers: + break + + masked_residual = residual * mask.unsqueeze(1) + z_q_i, indices_i, _ = quantizer(masked_residual) + + update_mask = mask.unsqueeze(1) + quantized_out = quantized_out + z_q_i * update_mask + residual = residual - z_q_i * update_mask + all_indices.append(indices_i) + + all_indices = torch.stack(all_indices) # (N, B, T) + quantized_out = self.output_proj(quantized_out) + + return quantized_out, all_indices, input_length + + def decode_codes(self, codes: torch.Tensor) -> torch.Tensor: + """Decode codes from multiple quantizers to embeddings.""" + nq, B, T = codes.shape + emb = torch.zeros(B, self.rvq_dim, T, device=codes.device, dtype=torch.float32) + + for i, quantizer in enumerate(self.quantizers[:nq]): + quantizer = cast(MossAudioTokenizerVectorQuantize, quantizer) + quantized_i = quantizer.decode_code(codes[i]) + emb += quantized_i + + emb = self.output_proj(emb) + return emb + + +class MossAudioTokenizerResidualLFQ(nn.Module): + """Residual LFQ (inference only).""" + + def __init__( + self, + input_dim: int = 1024, + rvq_dim: int | None = None, + output_dim: int | None = None, + num_quantizers: int = 32, + codebook_size: int = 1024, + codebook_dim: int = 8, + **kwargs, + ): + super().__init__() + self.input_dim = input_dim + self.rvq_dim = rvq_dim or input_dim + self.output_dim = output_dim or input_dim + self.num_quantizers = num_quantizers + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + self.input_proj = ( + WNConv1d(input_dim, self.rvq_dim, kernel_size=1) if input_dim != self.rvq_dim else nn.Identity() + ) + self.output_proj = ( + WNConv1d(self.rvq_dim, self.output_dim, kernel_size=1) if self.rvq_dim != self.output_dim else nn.Identity() + ) + + self.quantizers = nn.ModuleList( + [ + MossAudioTokenizerLFQ( + input_dim=self.rvq_dim, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + **kwargs, + ) + for _ in range(num_quantizers) + ] + ) + + @torch.no_grad() + def forward( + self, + z: torch.Tensor, + input_length: torch.Tensor, + n_quantizers: int | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Inference quantization.""" + z = self.input_proj(z).float() + + batch_size, _, max_time = z.shape + mask = torch.arange(max_time, device=z.device).expand(batch_size, max_time) < input_length.unsqueeze(1) + + quantized_out = torch.zeros_like(z, dtype=torch.float32) + residual = z.clone().float() + all_indices = [] + + n_quantizers = n_quantizers or self.num_quantizers + for i, quantizer in enumerate(self.quantizers): + if i >= n_quantizers: + break + + masked_residual = residual * mask.unsqueeze(1) + z_q_i, indices_i, _ = quantizer(masked_residual) + + update_mask = mask.unsqueeze(1) + quantized_out = quantized_out + z_q_i * update_mask + residual = residual - z_q_i * update_mask + all_indices.append(indices_i) + + all_indices = ( + torch.stack(all_indices) + if all_indices + else torch.empty(0, batch_size, max_time, device=z.device, dtype=torch.long) + ) + quantized_out = self.output_proj(quantized_out) + return quantized_out, all_indices, input_length + + def decode_codes(self, codes: torch.Tensor) -> torch.Tensor: + nq, B, T = codes.shape + emb = torch.zeros(B, self.rvq_dim, T, device=codes.device, dtype=torch.float32) + for i, quantizer in enumerate(self.quantizers[:nq]): + quantizer = cast(MossAudioTokenizerLFQ, quantizer) + emb += quantizer.decode_code(codes[i]).float() + emb = self.output_proj(emb) + return emb + + +# ============================================================================= +# Main Model Classes +# ============================================================================= + + +@auto_docstring +class MossAudioTokenizerPreTrainedModel(PreTrainedAudioTokenizerBase): + """Base class for MossAudioTokenizer models.""" + + config_class = MossAudioTokenizerConfig + base_model_prefix = "" + main_input_name = "input_values" + input_modalities = "audio" + supports_gradient_checkpointing = False + _no_split_modules = [ + "MossAudioTokenizerTransformerLayer", + "MossAudioTokenizerResidualVQ", + "MossAudioTokenizerResidualLFQ", + ] + + +@auto_docstring( + custom_intro=""" + The MossAudioTokenizer neural audio codec model for audio tokenization and synthesis. + """ +) +class MossAudioTokenizerModel(MossAudioTokenizerPreTrainedModel): + """ + MossAudioTokenizer model for audio tokenization and synthesis. + + This model can encode audio waveforms into discrete tokens and decode + tokens back into audio waveforms. + """ + + def __init__(self, config: MossAudioTokenizerConfig): + super().__init__(config) + + self.config = config + _ = config.version + self.sampling_rate = config.sampling_rate + self.downsample_rate = config.downsample_rate + self.number_channels = getattr(config, "number_channels", getattr(config, "num_channels", 1)) + self.enable_channel_interleave = getattr(config, "enable_channel_interleave", True) + self.causal_transformer_context_duration = config.causal_transformer_context_duration + + # Build encoder + channel_interleave_factor = ( + self.number_channels if self.enable_channel_interleave and self.number_channels > 1 else 1 + ) + current_frame_rate: float = float(self.sampling_rate * channel_interleave_factor) + self.encoder = nn.ModuleList() + + for encoder_kwargs_i in config.encoder_kwargs: + encoder_kwargs_i = dict(encoder_kwargs_i) # Make a copy + if encoder_kwargs_i["module_type"] == "PatchedPretransform": + self.encoder.append(MossAudioTokenizerPatchedPretransform(**encoder_kwargs_i, is_downsample=True)) + elif encoder_kwargs_i["module_type"] == "Transformer": + context_duration = float( + encoder_kwargs_i.pop("context_duration", self.causal_transformer_context_duration) + ) + self.encoder.append( + MossAudioTokenizerProjectedTransformer( + **encoder_kwargs_i, + context=int(round(current_frame_rate * context_duration)), + ) + ) + current_frame_rate /= self.encoder[-1].downsample_ratio + + # Build quantizer + quantizer_kwargs = dict(config.quantizer_kwargs) + quantizer_type = quantizer_kwargs.get("quantizer_type", getattr(config, "quantizer_type", "rvq")) + if quantizer_type in {"rvq", "spec_rvq"}: + self.quantizer = MossAudioTokenizerResidualVQ(**quantizer_kwargs) + elif quantizer_type in {"rlfq", "random_prefix_rlfq"}: + self.quantizer = MossAudioTokenizerResidualLFQ(**quantizer_kwargs) + else: + raise ValueError(f"Unsupported quantizer_type: {quantizer_type}") + + # Build decoder + decoder_kwargs_list = copy.deepcopy(config.decoder_kwargs) + self.decoder = nn.ModuleList() + + for decoder_kwargs_i in decoder_kwargs_list: + decoder_kwargs_i = dict(decoder_kwargs_i) + if decoder_kwargs_i["module_type"] == "PatchedPretransform": + self.decoder.append(MossAudioTokenizerPatchedPretransform(**decoder_kwargs_i, is_downsample=False)) + elif decoder_kwargs_i["module_type"] == "Transformer": + context_duration = float( + decoder_kwargs_i.pop("context_duration", self.causal_transformer_context_duration) + ) + self.decoder.append( + MossAudioTokenizerProjectedTransformer( + **decoder_kwargs_i, + context=int(round(current_frame_rate * context_duration)), + ) + ) + current_frame_rate *= self.decoder[-1].downsample_ratio + + expected_output_frame_rate = float(self.sampling_rate * channel_interleave_factor) + if int(round(current_frame_rate)) != int(round(expected_output_frame_rate)): + raise ValueError( + "Decoder stack does not invert the encoder frame rate correctly: " + f"got current_frame_rate={current_frame_rate}, expected={expected_output_frame_rate}." + ) + + self.post_init() + + def _start_streaming(self, batch_size: int): + """Start streaming mode for all modules.""" + + def _start(module): + if isinstance(module, StreamingModule): + module._streaming_state = module._init_streaming_state(batch_size) + + self.apply(_start) + + def _stop_streaming(self): + """Stop streaming mode for all modules.""" + + def _stop(module): + if isinstance(module, StreamingModule): + module._streaming_state = None + + self.apply(_stop) + + def _set_streaming_exec_mask(self, exec_mask: torch.Tensor) -> None: + """Set the active slot mask on every live streaming module.""" + + def _set(module): + if isinstance(module, StreamingModule) and module._streaming_state is not None: + module._streaming_state.set_exec_mask(exec_mask.to(module._streaming_state.device)) + + self.apply(_set) + + @contextmanager + def streaming(self, batch_size: int = 1): + """Context manager for streaming mode.""" + self._start_streaming(batch_size) + try: + yield + finally: + self._stop_streaming() + + def _prepare_waveform_batch(self, wav_list: list[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]: + if len(wav_list) == 0: + raise ValueError("`wav_list` must contain at least one waveform.") + + device = wav_list[0].device + dtype = wav_list[0].dtype + batch_size = len(wav_list) + input_lengths = torch.zeros(batch_size, device=device, dtype=torch.long) + + normalized_wavs: list[torch.Tensor] = [] + for i, wav in enumerate(wav_list): + if self.number_channels == 1: + if wav.dim() == 1: + wav_i = wav.unsqueeze(0) + elif wav.dim() == 2 and wav.shape[0] == 1: + wav_i = wav + else: + raise ValueError( + f"Expected wav_list[{i}] to have shape `(T,)` or `(1, T)` for a mono model, " + f"got {tuple(wav.shape)}." + ) + else: + if wav.dim() != 2 or wav.shape[0] != self.number_channels: + raise ValueError( + f"Expected wav_list[{i}] to have shape `({self.number_channels}, T)`, got {tuple(wav.shape)}." + ) + wav_i = wav + + normalized_wavs.append(wav_i) + input_lengths[i] = wav_i.shape[-1] + + max_length = int(input_lengths.max().item()) if batch_size > 0 else 0 + input_values = torch.zeros( + batch_size, + self.number_channels, + max_length, + device=device, + dtype=dtype, + ) + for i, wav_i in enumerate(normalized_wavs): + input_values[i, :, : wav_i.shape[-1]] = wav_i + return input_values, input_lengths + + def _prepare_codes_batch( + self, + codes_list: list[torch.Tensor], + num_quantizers: int | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, int]: + if len(codes_list) == 0: + raise ValueError("`codes_list` must contain at least one code tensor.") + + batch_size = len(codes_list) + device = codes_list[0].device + nqs = [codes.shape[0] for codes in codes_list] + if num_quantizers is None: + num_quantizers = nqs[0] + if any(nq != num_quantizers for nq in nqs): + raise ValueError( + "All elements in `codes_list` must have the same number of quantizers when " + "`num_quantizers` is None. Pass `num_quantizers=...` to decode a common prefix." + ) + else: + min_nq = min(nqs) + if min_nq < num_quantizers: + raise ValueError( + "`num_quantizers` must be <= the number of quantizers for every element in `codes_list`. " + f"Got num_quantizers={num_quantizers}, min(codes.shape[0])={min_nq}." + ) + + max_length = max(codes.shape[-1] for codes in codes_list) + audio_codes = torch.zeros(num_quantizers, batch_size, max_length, device=device, dtype=torch.long) + audio_codes_lengths = torch.zeros(batch_size, device=device, dtype=torch.long) + + for i, codes in enumerate(codes_list): + codes_i = codes[:num_quantizers] + audio_codes[:, i, : codes_i.shape[-1]] = codes_i + audio_codes_lengths[i] = codes_i.shape[-1] + return audio_codes, audio_codes_lengths, num_quantizers + + def _flatten_channels_for_codec( + self, + input_values: torch.Tensor, + input_lengths: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + if input_values.dim() != 3: + raise ValueError(f"Expected `input_values` with shape `(B, C, T)`, got {tuple(input_values.shape)}.") + if input_values.shape[1] != self.number_channels: + raise ValueError( + f"Expected `input_values.shape[1] == {self.number_channels}`, got {input_values.shape[1]}." + ) + + if input_values.shape[-1] % self.downsample_rate != 0: + pad_length = self.downsample_rate - (input_values.shape[-1] % self.downsample_rate) + input_values = F.pad(input_values, (0, pad_length)) + + if self.number_channels > 1 and self.enable_channel_interleave: + input_values = input_values.transpose(1, 2).contiguous().view(input_values.shape[0], 1, -1) + input_lengths = input_lengths * self.number_channels + return input_values, input_lengths + + def _restore_channels_from_codec( + self, + output_values: torch.Tensor, + output_lengths: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + if self.number_channels == 1 or not self.enable_channel_interleave: + return output_values.float(), output_lengths + + output_values = ( + output_values.squeeze(1) + .contiguous() + .view(output_values.shape[0], -1, self.number_channels) + .transpose(1, 2) + .contiguous() + .float() + ) + output_lengths = torch.div(output_lengths, self.number_channels, rounding_mode="floor") + return output_values, output_lengths + + @torch.no_grad() + def batch_encode( + self, wav_list: list[torch.Tensor], num_quantizers: int | None = None + ) -> MossAudioTokenizerEncoderOutput: + """Batch encode a list of audio waveforms. + + Args: + wav_list: List of audio tensors, each of shape `(num_samples,)`. + num_quantizers: Number of quantizers to use. By default, all quantizers are used. + + Returns: + [`MossAudioTokenizerEncoderOutput`] with `audio_codes` and `audio_codes_lengths`. + """ + input_values, input_lengths = self._prepare_waveform_batch(wav_list) + return self._encode_frame(input_values, input_lengths, n_quantizers=num_quantizers) + + @torch.no_grad() + def batch_decode( + self, codes_list: list[torch.Tensor], num_quantizers: int | None = None + ) -> MossAudioTokenizerDecoderOutput: + """Batch decode a list of audio codes. + + Args: + codes_list: List of audio code tensors, each of shape `(num_quantizers, codes_length)`. + num_quantizers: If provided, decode only the first `num_quantizers` quantizers from each element in + `codes_list`. If omitted, all elements in `codes_list` must have the same number of quantizers. + + Returns: + [`MossAudioTokenizerDecoderOutput`] with `audio` and `audio_lengths`. + """ + audio_codes, audio_codes_lengths, _ = self._prepare_codes_batch( + codes_list, + num_quantizers=num_quantizers, + ) + return self._decode_frame(audio_codes, audio_codes_lengths) + + @torch.no_grad() + def _encode_frame( + self, + input_values: torch.Tensor, + input_lengths: torch.Tensor | None = None, + n_quantizers: int | None = None, + ) -> MossAudioTokenizerEncoderOutput: + """Tokenize audio waveform into discrete tokens.""" + # Handle input shape + if input_values.dim() == 1: + input_values = input_values.view(1, 1, -1) + elif input_values.dim() == 2: + if self.number_channels == 1: + input_values = input_values.unsqueeze(1) + else: + input_values = input_values.unsqueeze(0) + + B, _, T = input_values.shape + device = input_values.device + + if input_lengths is None: + input_lengths = torch.full((B,), T, device=device, dtype=torch.long) + + input_values, input_lengths = self._flatten_channels_for_codec(input_values, input_lengths) + + # Encode + e, e_lengths = input_values, input_lengths + for encoder_module in self.encoder: + e, e_lengths = encoder_module(e, e_lengths) + + # Quantize + quantizer = cast(MossAudioTokenizerResidualVQ | MossAudioTokenizerResidualLFQ, self.quantizer) + zq, audio_codes, audio_codes_lengths = quantizer(e, e_lengths, n_quantizers) + + return MossAudioTokenizerEncoderOutput( + audio_codes=audio_codes, audio_codes_lengths=audio_codes_lengths, encoder_hidden_states=e + ) + + @torch.no_grad() + def _decode_frame( + self, + codes: torch.Tensor, + codes_lengths: torch.Tensor | None = None, + ) -> MossAudioTokenizerDecoderOutput: + """Detokenize discrete tokens into audio waveform.""" + nq, B, T = codes.shape + device = codes.device + + if codes_lengths is None: + codes_lengths = torch.full((B,), T, device=device, dtype=torch.long) + + # Decode from codes + quantizer = cast(MossAudioTokenizerResidualVQ | MossAudioTokenizerResidualLFQ, self.quantizer) + zq = quantizer.decode_codes(codes) + + d, d_lengths = zq, codes_lengths + for decoder_module in self.decoder: + d, d_lengths = decoder_module(d, d_lengths) + + d, d_lengths = self._restore_channels_from_codec(d, d_lengths) + return MossAudioTokenizerDecoderOutput(audio=d, audio_lengths=d_lengths) + + def encode( # type: ignore[override] + self, + input_values: torch.Tensor, + padding_mask: torch.Tensor | None = None, + num_quantizers: int | None = None, + return_dict: bool | None = None, + chunk_duration: float | None = None, + ) -> MossAudioTokenizerEncoderOutput | tuple[torch.Tensor, torch.Tensor]: + """ + Encodes the input audio waveform into discrete codes. + + Args: + input_values (`torch.Tensor` of shape `(batch_size, channels, sequence_length)`): + Float values of the input audio waveform. + padding_mask (`torch.Tensor` of shape `(batch_size, sequence_length)`, *optional*): + Mask to indicate valid audio samples. + num_quantizers (`int`, *optional*): + Number of quantizers to use. By default, all quantizers are used. + return_dict (`bool`, *optional*): + Whether or not to return a [`~utils.ModelOutput`] instead of a plain tuple. + chunk_duration (`float`, *optional*): + If provided, encode the input waveform in successive chunks of `chunk_duration` seconds while keeping a + streaming KV cache for the causal transformers. + + `chunk_duration` must be <= `config.causal_transformer_context_duration`, and + `chunk_duration * config.sampling_rate` must be divisible by `config.downsample_rate`. + + Returns: + `MossAudioTokenizerEncoderOutput` or tuple containing audio codes and lengths. + """ + return_dict = return_dict if return_dict is not None else self.config.return_dict + + # Handle input shape + if input_values.dim() == 1: + input_values = input_values.view(1, 1, -1) + elif input_values.dim() == 2: + if self.number_channels == 1: + input_values = input_values.unsqueeze(1) + else: + input_values = input_values.unsqueeze(0) + + B, _, T = input_values.shape + device = input_values.device + + if padding_mask is not None: + input_lengths = padding_mask.sum(dim=-1).long() + else: + input_lengths = torch.full((B,), T, device=device, dtype=torch.long) + + if chunk_duration is None: + encoder_output = self._encode_frame(input_values, input_lengths, num_quantizers) + else: + if chunk_duration <= 0: + raise ValueError("`chunk_duration` must be > 0 when provided.") + if chunk_duration > self.causal_transformer_context_duration: + raise ValueError( + "`chunk_duration` must be <= `config.causal_transformer_context_duration` " + f"({self.causal_transformer_context_duration}), got {chunk_duration}." + ) + if B != 1: + raise ValueError("Streaming encode via `chunk_duration` currently only supports batch_size=1.") + + chunk_length = int(round(chunk_duration * self.sampling_rate)) + if chunk_length <= 0: + raise ValueError("`chunk_duration` is too small and results in chunk_length <= 0.") + if chunk_length % self.downsample_rate != 0: + raise ValueError( + "`chunk_duration * config.sampling_rate` must be divisible by `config.downsample_rate`. " + f"Got chunk_length={chunk_length}, downsample_rate={self.downsample_rate}." + ) + + input_length = int(input_lengths[0].item()) + if input_length <= chunk_length: + encoder_output = self._encode_frame(input_values[..., :input_length], input_lengths, num_quantizers) + else: + codes_chunks: list[torch.Tensor] = [] + hidden_chunks: list[torch.Tensor] = [] + + with ExitStack() as exit_stack: + for encoder_module in self.encoder: + if isinstance(encoder_module, StreamingModule): + exit_stack.enter_context(encoder_module.streaming(batch_size=B)) + + for start_idx in range(0, input_length, chunk_length): + input_length_i = min(chunk_length, input_length - start_idx) + if input_length_i <= 0: + break + + input_lengths_i = torch.tensor([input_length_i], device=device, dtype=torch.long) + input_values_i = input_values[..., start_idx : start_idx + input_length_i] + result_i = self._encode_frame(input_values_i, input_lengths_i, num_quantizers) + + if result_i.audio_codes is None or result_i.audio_codes_lengths is None: + raise RuntimeError("Internal error: `_encode_frame` returned empty audio codes.") + if result_i.encoder_hidden_states is None: + raise RuntimeError("Internal error: `_encode_frame` returned empty encoder hidden states.") + + codes_length_i = result_i.audio_codes_lengths + codes_chunks.append(result_i.audio_codes[:, :, : codes_length_i[0]]) + hidden_chunks.append(result_i.encoder_hidden_states[:, :, : codes_length_i[0]]) + + audio_codes = torch.cat(codes_chunks, dim=-1) + encoder_hidden_states = torch.cat(hidden_chunks, dim=-1) + audio_codes_lengths = torch.tensor([audio_codes.shape[-1]], device=device, dtype=torch.long) + encoder_output = MossAudioTokenizerEncoderOutput( + audio_codes=audio_codes, + audio_codes_lengths=audio_codes_lengths, + encoder_hidden_states=encoder_hidden_states, + ) + + if not return_dict: + assert encoder_output.audio_codes is not None + assert encoder_output.audio_codes_lengths is not None + return ( + cast(torch.Tensor, encoder_output.audio_codes), + cast(torch.Tensor, encoder_output.audio_codes_lengths), + ) + return encoder_output + + def decode( # type: ignore[override] + self, + audio_codes: torch.Tensor, + padding_mask: torch.Tensor | None = None, + return_dict: bool | None = None, + chunk_duration: float | None = None, + num_quantizers: int | None = None, + ) -> MossAudioTokenizerDecoderOutput | tuple[torch.Tensor]: + """ + Decodes the given codes into an output audio waveform. + + Args: + audio_codes (`torch.LongTensor` of shape `(num_quantizers, batch_size, sequence_length)`): + Discrete code embeddings computed using `model.encode`. + padding_mask (`torch.Tensor` of shape `(batch_size, sequence_length)`, *optional*): + Mask to indicate valid code positions. + return_dict (`bool`, *optional*): + Whether or not to return a [`~utils.ModelOutput`] instead of a plain tuple. + chunk_duration (`float`, *optional*): + If provided, decode the input codes in successive chunks of `chunk_duration` seconds while keeping a + streaming KV cache for the causal transformers. + + num_quantizers (`int`, *optional*): + Number of quantizers to use. By default, all quantizers in `audio_codes` are used. + + `chunk_duration` must be <= `config.causal_transformer_context_duration`, and + `chunk_duration * config.sampling_rate` must be divisible by `config.downsample_rate`. + + Returns: + `MossAudioTokenizerDecoderOutput` or tuple containing decoded audio. + """ + return_dict = return_dict if return_dict is not None else self.config.return_dict + + if audio_codes.dim() == 2: + audio_codes = audio_codes.unsqueeze(1) # nq, T -> nq, B=1, T + + if num_quantizers is not None: + if num_quantizers > audio_codes.shape[0]: + raise ValueError( + f"`num_quantizers` ({num_quantizers}) must be <= audio_codes.shape[0] ({audio_codes.shape[0]})." + ) + audio_codes = audio_codes[:num_quantizers] + + _, B, T = audio_codes.shape + device = audio_codes.device + + if padding_mask is not None: + codes_lengths = padding_mask.sum(dim=-1).long() + else: + codes_lengths = torch.full((B,), T, device=device, dtype=torch.long) + + if chunk_duration is None: + decoder_output = self._decode_frame(audio_codes, codes_lengths) + else: + if chunk_duration <= 0: + raise ValueError("`chunk_duration` must be > 0 when provided.") + if chunk_duration > self.causal_transformer_context_duration: + raise ValueError( + "`chunk_duration` must be <= `config.causal_transformer_context_duration` " + f"({self.causal_transformer_context_duration}), got {chunk_duration}." + ) + if B != 1: + raise ValueError("Streaming decode via `chunk_duration` currently only supports batch_size=1.") + + chunk_length = int(round(chunk_duration * self.sampling_rate)) + if chunk_length <= 0: + raise ValueError("`chunk_duration` is too small and results in chunk_length <= 0.") + if chunk_length % self.downsample_rate != 0: + raise ValueError( + "`chunk_duration * config.sampling_rate` must be divisible by `config.downsample_rate`. " + f"Got chunk_length={chunk_length}, downsample_rate={self.downsample_rate}." + ) + + chunk_frame_length = chunk_length // self.downsample_rate + codes_length = int(codes_lengths[0].item()) + if codes_length <= chunk_frame_length: + decoder_output = self._decode_frame(audio_codes[..., :codes_length], codes_lengths) + else: + wav_chunks: list[torch.Tensor] = [] + with ExitStack() as exit_stack: + for decoder_module in self.decoder: + if isinstance(decoder_module, StreamingModule): + exit_stack.enter_context(decoder_module.streaming(batch_size=B)) + + for start_idx in range(0, codes_length, chunk_frame_length): + codes_length_i = min(chunk_frame_length, codes_length - start_idx) + if codes_length_i <= 0: + break + + codes_lengths_i = torch.tensor([codes_length_i], device=device, dtype=torch.long) + codes_i = audio_codes[:, :, start_idx : start_idx + codes_length_i] + result_i = self._decode_frame(codes_i, codes_lengths_i) + + if result_i.audio is None or result_i.audio_lengths is None: + raise RuntimeError("Internal error: `_decode_frame` returned empty audio.") + + wav_chunks.append(result_i.audio[:, :, : result_i.audio_lengths[0]]) + + wav = torch.cat(wav_chunks, dim=-1) + audio_lengths = torch.tensor([wav.shape[-1]], device=device, dtype=torch.long) + decoder_output = MossAudioTokenizerDecoderOutput(audio=wav, audio_lengths=audio_lengths) + + if not return_dict: + assert decoder_output.audio is not None + return (cast(torch.Tensor, decoder_output.audio),) + return decoder_output + + @auto_docstring + def forward( + self, + input_values: torch.FloatTensor | None = None, + padding_mask: torch.BoolTensor | None = None, + audio_codes: torch.Tensor | None = None, + num_quantizers: int | None = None, + return_dict: bool | None = None, + ) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None] | MossAudioTokenizerOutput: # type: ignore[override] + r""" + input_values (`torch.FloatTensor` of shape `(batch_size, channels, sequence_length)`, *optional*): + Raw audio input converted to Float. + padding_mask (`torch.BoolTensor` of shape `(batch_size, sequence_length)`, *optional*): + Mask to avoid computing on padding token indices. Mask values selected in `[0, 1]`: + - 1 for tokens that are **not masked**, + - 0 for tokens that are **masked**. + audio_codes (`torch.LongTensor` of shape `(num_quantizers, batch_size, sequence_length)`, *optional*): + Discrete code embeddings computed using `model.encode`. + num_quantizers (`int`, *optional*): + Number of quantizers (codebooks) to use. By default, all quantizers are used. + return_dict (`bool`, *optional*): + Whether or not to return a [`~utils.ModelOutput`] instead of a plain tuple. + + Examples: + + ```python + >>> import torch + >>> from transformers import MossAudioTokenizerModel + + >>> model = MossAudioTokenizerModel.from_pretrained("moss_audio_tokenizer-model") + + >>> # Create dummy audio input + >>> audio = torch.randn(1, 1, 24000) # 1 second of audio at 24kHz + + >>> outputs = model(input_values=audio) + >>> audio_codes = outputs.audio_codes + >>> audio_values = outputs.audio + ``` + """ + return_dict = return_dict if return_dict is not None else self.config.return_dict + + output_audio_codes: torch.Tensor | None = None + output_audio_codes_lengths: torch.Tensor | None = None + output_audio: torch.Tensor | None = None + output_audio_lengths: torch.Tensor | None = None + decoded_from_encoded_codes = False + + # Encode if input_values provided + if input_values is not None: + encoder_output = self.encode(input_values, padding_mask, num_quantizers, return_dict=True) + encoder_output = cast(MossAudioTokenizerEncoderOutput, encoder_output) + output_audio_codes = encoder_output.audio_codes + output_audio_codes_lengths = encoder_output.audio_codes_lengths + + # If codes not provided separately, use encoded codes for decoding + if audio_codes is None: + audio_codes = output_audio_codes + decoded_from_encoded_codes = True + + # Decode if codes available + if audio_codes is not None: + # If we're decoding the codes we just produced, use the computed lengths so we don't decode padded garbage. + if decoded_from_encoded_codes and output_audio_codes_lengths is not None: + decoder_output = self._decode_frame(audio_codes, output_audio_codes_lengths) + else: + decoder_output = self.decode( + audio_codes, + padding_mask=padding_mask, + return_dict=True, + num_quantizers=num_quantizers, + ) + decoder_output = cast(MossAudioTokenizerDecoderOutput, decoder_output) + output_audio = decoder_output.audio + output_audio_lengths = decoder_output.audio_lengths + + if not return_dict: + return (output_audio_codes, output_audio, output_audio_lengths) + + return MossAudioTokenizerOutput( + audio=output_audio, + audio_lengths=output_audio_lengths, + audio_codes=output_audio_codes, + audio_codes_lengths=output_audio_codes_lengths, + ) + + +__all__ = ["MossAudioTokenizerModel", "MossAudioTokenizerPreTrainedModel"] diff --git a/vllm_omni/model_executor/models/moss_tts/configuration_moss_audio_tokenizer_v2.py b/vllm_omni/model_executor/models/moss_tts/configuration_moss_audio_tokenizer_v2.py new file mode 100644 index 000000000..c518742a2 --- /dev/null +++ b/vllm_omni/model_executor/models/moss_tts/configuration_moss_audio_tokenizer_v2.py @@ -0,0 +1,359 @@ +# Copyright 2026 OpenMOSS and the HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MossAudioTokenizer model configuration""" + +from typing import Any + +from transformers.configuration_utils import PreTrainedConfig +from transformers.utils import logging + +logger = logging.get_logger(__name__) + + +class MossAudioTokenizerConfig(PreTrainedConfig): + r""" + This is the configuration class to store the configuration of a [`MossAudioTokenizerModel`]. It is used to + instantiate a MossAudioTokenizer model according to the specified arguments, defining the model architecture. + + Instantiating a configuration with the defaults will yield a similar configuration to that of the + [VoiceAgentGroup/moss_audio_tokenizer](https://huggingface.co/VoiceAgentGroup/moss_audio_tokenizer) architecture. + + Configuration objects inherit from [`PreTrainedConfig`] and can be used to control the model outputs. Read the + documentation from [`PreTrainedConfig`] for more information. + + Args: + sampling_rate (`int`, *optional*, defaults to 24000): + The sampling rate at which the audio waveform should be digitalized expressed in hertz (Hz). + downsample_rate (`int`, *optional*, defaults to 1920): + Total downsampling rate from waveform to tokens. + causal_transformer_context_duration (`float`, *optional*, defaults to 10.0): + Context duration in seconds for causal transformer. + encoder_kwargs (`list[dict]`, *optional*): + List of encoder module configurations. Each dict specifies a module type and its parameters. + decoder_kwargs (`list[dict]`, *optional*): + List of decoder module configurations in execution order. + quantizer_type (`str`, *optional*, defaults to `"rvq"`): + Quantizer type. Options include `"rvq"`, `"spec_rvq"`, `"rlfq"`, `"random_prefix_rlfq"`. + quantizer_kwargs (`dict`, *optional*): + Configuration for the quantizer including `input_dim`, `rvq_dim`, `output_dim`, `num_quantizers`, + `codebook_size`, and `codebook_dim`. + + Example: + + ```python + >>> from transformers import MossAudioTokenizerModel, MossAudioTokenizerConfig + + >>> # Initializing a MossAudioTokenizer style configuration + >>> configuration = MossAudioTokenizerConfig() + + >>> # Initializing a model (with random weights) from the configuration + >>> model = MossAudioTokenizerModel(configuration) + + >>> # Accessing the model configuration + >>> configuration = model.config + ``` + """ + + model_type = "moss-audio-tokenizer" + + # Backward-compatible alias used by some checkpoints. + attribute_map = {"sample_rate": "sampling_rate"} + + sampling_rate: int + downsample_rate: int + causal_transformer_context_duration: float + encoder_kwargs: list[dict[str, Any]] + decoder_kwargs: list[dict[str, Any]] + number_channels: int + enable_channel_interleave: bool + attention_implementation: str + compute_dtype: str + codec_weight_dtype: str + quantizer_type: str + quantizer_kwargs: dict[str, Any] + + def __init__( + self, + version: str | None = None, + sampling_rate: int = 48000, + downsample_rate: int = 3840, + causal_transformer_context_duration: float = 10.0, + encoder_kwargs: list[dict[str, Any]] | None = None, + decoder_kwargs: list[dict[str, Any]] | None = None, + number_channels: int = 2, + enable_channel_interleave: bool = True, + attention_implementation: str = "sdpa", + compute_dtype: str = "fp32", + codec_weight_dtype: str = "fp32", + quantizer_type: str = "rlfq", + quantizer_kwargs: dict[str, Any] | None = None, + **kwargs, + ): + # Some checkpoints might include an incorrect/legacy `model_type` (e.g. "speech_tokenizer"). + # We drop it to avoid overriding the class-level `model_type`. + kwargs.pop("model_type", None) + if "channels_numbers" in kwargs: + number_channels = kwargs.pop("channels_numbers") + if "enable_channel_interleave" in kwargs: + enable_channel_interleave = kwargs.pop("enable_channel_interleave") + if "attention_backend" in kwargs and attention_implementation == "sdpa": + attention_implementation = kwargs.pop("attention_backend") + if "codec_compute_dtype" in kwargs and compute_dtype == "fp32": + compute_dtype = kwargs.pop("codec_compute_dtype") + if "codec_load_dtype" in kwargs and codec_weight_dtype == "fp32": + codec_weight_dtype = kwargs.pop("codec_load_dtype") + + # `version` is accepted for compatibility but not used in modeling. + self.version = version + self.sampling_rate = sampling_rate + self.downsample_rate = downsample_rate + self.causal_transformer_context_duration = causal_transformer_context_duration + self.number_channels = number_channels + self.enable_channel_interleave = enable_channel_interleave + self.attention_implementation = attention_implementation + self.compute_dtype = compute_dtype + self.codec_weight_dtype = codec_weight_dtype + # Default encoder configuration + if encoder_kwargs is None: + encoder_kwargs = [ + { + "module_type": "PatchedPretransform", + "patch_size": 240, + }, + { + "module_type": "Transformer", + "input_dimension": 240, + "output_dimension": 384, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 768, + "output_dimension": 384, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 768, + "output_dimension": 640, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 1280, + "output_dimension": 768, + "d_model": 1280, + "num_heads": 20, + "num_layers": 32, + "dim_feedforward": 5120, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + ] + self.encoder_kwargs = encoder_kwargs + + # Default decoder configuration (execution order) + if decoder_kwargs is None: + decoder_kwargs = [ + { + "module_type": "Transformer", + "input_dimension": 768, + "output_dimension": 1280, + "d_model": 1280, + "num_heads": 20, + "num_layers": 32, + "dim_feedforward": 5120, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 640, + "output_dimension": 768, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 384, + "output_dimension": 768, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 384, + "output_dimension": 768, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2, + }, + { + "module_type": "Transformer", + "input_dimension": 384, + "output_dimension": 240, + "d_model": 768, + "num_heads": 12, + "num_layers": 12, + "dim_feedforward": 3072, + "causal": True, + "norm": "layer_norm", + "positional_embedding": "rope", + "max_period": 10000, + "gating": "none", + "layer_scale": 0.01, + "conv_layout": True, + }, + { + "module_type": "PatchedPretransform", + "patch_size": 240, + }, + ] + self.decoder_kwargs = decoder_kwargs + + # Default quantizer configuration + if quantizer_kwargs is None: + quantizer_kwargs = { + "input_dim": 768, + "rvq_dim": 512, + "output_dim": 768, + "num_quantizers": 32, + "codebook_size": 1024, + "codebook_dim": 8, + "quantizer_type": "rlfq", + } + + # Handle quantizer_type from kwargs or config + kw_qtype = quantizer_kwargs.get("quantizer_type", None) + if kw_qtype is not None: + self.quantizer_type = kw_qtype + else: + self.quantizer_type = quantizer_type + quantizer_kwargs["quantizer_type"] = quantizer_type + + self.quantizer_kwargs = quantizer_kwargs + + super().__init__(**kwargs) + + @property + def num_quantizers(self) -> int: + """Return the number of quantizers from quantizer_kwargs.""" + return self.quantizer_kwargs.get("num_quantizers", 32) + + @property + def codebook_size(self) -> int: + """Return the codebook size from quantizer_kwargs.""" + return self.quantizer_kwargs.get("codebook_size", 4096) + + @property + def frame_rate(self) -> float: + """Return the frame rate (tokens per second).""" + return self.sampling_rate / self.downsample_rate + + +__all__ = ["MossAudioTokenizerConfig"] diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py index 7c5d47653..60f455240 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py @@ -5,6 +5,7 @@ from __future__ import annotations +import contextlib from collections.abc import Iterable from typing import Any @@ -19,6 +20,12 @@ from vllm_omni.model_executor.models.moss_tts.audio_tokenizer import ( MossAudioTokenizerConfig, MossAudioTokenizerModel, ) +from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import ( + MossAudioTokenizerModel as MossAudioTokenizerV2Model, +) +from vllm_omni.model_executor.models.moss_tts.configuration_moss_audio_tokenizer_v2 import ( + MossAudioTokenizerConfig as MossAudioTokenizerV2Config, +) from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import ( MossTTSCUDAGraphCodecWrapper, ) @@ -27,6 +34,96 @@ from vllm_omni.model_executor.models.output_templates import OmniOutput logger = init_logger(__name__) +class _MossCodecStreamSession: + """Persistent streaming decode session for vendored MOSS-Audio-Tokenizer-v2.""" + + def __init__( + self, + codec: nn.Module, + *, + stream_slots: int, + n_vq: int, + ) -> None: + self._codec = codec + self._stream_slots = int(stream_slots) + self._batch_size = self._stream_slots + self._n_vq = int(n_vq) + self._device = next(codec.parameters()).device + self._free_stream_slots = list(range(self._stream_slots)) + self._exit_stack = contextlib.ExitStack() + self._closed = False + with torch.no_grad(): + self._exit_stack.enter_context(codec.streaming(self._batch_size)) + + def acquire(self) -> int | None: + if not self._free_stream_slots: + return None + return self._free_stream_slots.pop() + + def release(self, slot: int) -> None: + if self._closed: + return + self.reset_slots([slot]) + self._free_stream_slots.append(slot) + + def reset_slots(self, slots: list[int]) -> None: + if not slots: + return + reset_mask = torch.zeros(self._batch_size, dtype=torch.bool, device=self._device) + reset_mask[slots] = True + + def _reset(module: nn.Module) -> None: + state = getattr(module, "_streaming_state", None) + if state is not None: + state.reset(reset_mask.to(state.device)) + + with torch.no_grad(): + self._codec.apply(_reset) + + def close(self) -> None: + if self._closed: + return + with torch.no_grad(): + self._exit_stack.close() + self._closed = True + + @torch.no_grad() + def step(self, slot_codes: dict[int, torch.Tensor]) -> dict[int, torch.Tensor]: + if not slot_codes: + return {} + step_lengths = {int(codes.shape[1]) for codes in slot_codes.values()} + if len(step_lengths) != 1: + raise ValueError(f"MOSS codec streaming step needs uniform T, got {sorted(step_lengths)}") + (step_t,) = step_lengths + codes_step = torch.zeros( + self._n_vq, + self._batch_size, + step_t, + dtype=torch.long, + device=self._device, + ) + codes_lengths = torch.zeros(self._batch_size, dtype=torch.long, device=self._device) + exec_mask = torch.zeros(self._batch_size, dtype=torch.bool, device=self._device) + for slot, codes in slot_codes.items(): + codes_step[:, slot, :] = codes.to(device=self._device, dtype=torch.long) + codes_lengths[slot] = int(codes.shape[1]) + exec_mask[slot] = True + + self._codec._set_streaming_exec_mask(exec_mask) + result = self._codec._decode_frame(codes_step, codes_lengths) + if result.audio is None: + return {} + audio = result.audio.detach().to("cpu", torch.float32) + lengths = result.audio_lengths.detach().to("cpu") if result.audio_lengths is not None else None + out: dict[int, torch.Tensor] = {} + for slot in slot_codes: + wav = audio[slot] + if lengths is not None: + wav = wav[..., : int(lengths[slot].item())] + out[slot] = wav.contiguous() + return out + + class MossTTSCodecDecoder(nn.Module): """Stage-1 decoder for all MOSS-TTS variants. @@ -75,6 +172,12 @@ class MossTTSCodecDecoder(nn.Module): self._cuda_graph_wrapper: MossTTSCUDAGraphCodecWrapper | None = None self._n_channels: int = 1 self._sr_tensor = torch.tensor(self._OUTPUT_SAMPLE_RATE, dtype=torch.int32) + self._stream_session: _MossCodecStreamSession | None = None + self._stream_slots: int = self._connector_int("codec_stream_slots", default=0) + self._stream_max_step_frames: int = self._connector_int("codec_max_step_frames", default=100) + self._stream_req_slots: dict[str, int] = {} + self._stream_pending_codes: dict[str, list[torch.Tensor]] = {} + self._stream_starved_reqs: set[str] = set() # ------------------------------------------------------------------ # vLLM-Omni stubs (codec has no AR loop) @@ -112,9 +215,9 @@ class MossTTSCodecDecoder(nn.Module): chunk transfer adapter assigns those to ``request.prompt_token_ids``, which arrives here as ``input_ids`` concatenated across all requests. Per-request slice boundaries are computed from - ``kwargs["num_scheduled_tokens"]`` (a list of token counts, one per - request). ``runtime_additional_information`` carries per-request - metadata such as ``left_context_size``. + ``kwargs["seq_token_counts"]`` (token counts, one per request). + ``runtime_additional_information`` carries per-request metadata such as + ``left_context_size``. Returns ------- @@ -123,8 +226,8 @@ class MossTTSCodecDecoder(nn.Module): multimodal_outputs["sr"] — list of scalar int32 tensors """ sr_tensor = self._sr_tensor - empty = torch.zeros((0,), dtype=torch.float32) - info_list: list[dict[str, Any]] = runtime_additional_information or [{}] + empty = self._empty_audio() + info_list: list[dict[str, Any]] = list(runtime_additional_information or [{}]) num_req = max(len(info_list), 1) if self._codec is None: @@ -140,24 +243,47 @@ class MossTTSCodecDecoder(nn.Module): audios: list[torch.Tensor] = [empty] * num_req srs: list[torch.Tensor] = [sr_tensor] * num_req device = next(self._codec.parameters()).device + streaming_work: list[tuple[int, str, torch.Tensor, bool]] = [] if input_ids is None or input_ids.numel() == 0: + for i, wav in self._finish_empty_streaming_requests(info_list).items(): + audios[i] = wav.reshape(-1) if wav.ndim == 1 or int(wav.shape[0]) == 1 else wav return OmniOutput( text_hidden_states=None, multimodal_outputs={"model_outputs": audios, "sr": srs}, ) - # ``input_ids`` is concatenated across all requests; split by - # query_start_loc-style offsets from ``num_scheduled_tokens`` in - # kwargs if available, else assume one request. + # ``input_ids`` is concatenated across all requests. vLLM-Omni runners + # pass the per-request lengths via the shared code2wav contract + # ``seq_token_counts``. ids_flat = input_ids.reshape(-1).to(dtype=torch.long) - num_scheduled_tokens = kwargs.get("num_scheduled_tokens") - if isinstance(num_scheduled_tokens, (list, tuple)) and len(num_scheduled_tokens) == num_req: - offsets = [0] - for n in num_scheduled_tokens: - offsets.append(offsets[-1] + int(n)) - else: - offsets = [0, int(ids_flat.shape[0])] + token_counts = self._normalize_seq_token_counts(kwargs.get("seq_token_counts")) + if token_counts is None: + raise RuntimeError( + "MossTTS codec requires seq_token_counts; otherwise concatenated " + "codec tokens cannot be split per request." + ) + if sum(token_counts) != int(ids_flat.shape[0]): + raise RuntimeError( + "MossTTS codec seq_token_counts mismatch: " + f"counts={token_counts}, sum={sum(token_counts)}, input_tokens={int(ids_flat.shape[0])}." + ) + + num_req = len(token_counts) + if len(info_list) < num_req: + info_list.extend({} for _ in range(num_req - len(info_list))) + elif len(info_list) > num_req: + info_list = info_list[:num_req] + if len(audios) < num_req: + audios.extend(empty for _ in range(num_req - len(audios))) + srs.extend(sr_tensor for _ in range(num_req - len(srs))) + elif len(audios) > num_req: + audios = audios[:num_req] + srs = srs[:num_req] + + offsets = [0] + for n in token_counts: + offsets.append(offsets[-1] + int(n)) for i, info in enumerate(info_list): if i + 1 >= len(offsets): @@ -165,6 +291,14 @@ class MossTTSCodecDecoder(nn.Module): seg = ids_flat[offsets[i] : offsets[i + 1]] if seg.numel() == 0: continue + meta = (info.get("meta", {}) if isinstance(info, dict) else {}) or {} + finished = bool(meta.get("stream_finished", meta.get("finished", False))) + streaming_enabled = bool(meta.get("codec_streaming", False)) + code_flat_numel = meta.get("code_flat_numel") + if streaming_enabled and finished and code_flat_numel is not None and int(code_flat_numel) == 0: + for _, wav in self._finish_empty_streaming_requests([info]).items(): + audios[i] = wav.reshape(-1) if wav.ndim == 1 or int(wav.shape[0]) == 1 else wav + continue if seg.numel() % self._n_vq != 0: logger.warning( "MossTTS codec input length %d not divisible by n_vq %d; skipping.", @@ -181,14 +315,19 @@ class MossTTSCodecDecoder(nn.Module): codebook_size = self._codec.config.codebook_size codes_nq_t = codes_nq_t.clamp_(0, int(codebook_size) - 1) - meta = (info.get("meta", {}) if isinstance(info, dict) else {}) or {} - left_ctx = self._runtime_value(info, meta, "left_context_size", 0) + left_ctx = meta.get("left_context_size", 0) if isinstance(left_ctx, (list, tuple)): left_ctx = int(left_ctx[0]) if left_ctx else 0 elif isinstance(left_ctx, torch.Tensor): left_ctx = int(left_ctx.reshape(-1)[0].item()) if left_ctx.numel() else 0 left_ctx = int(left_ctx) + req_key = self._runtime_request_key(info, meta, i) + + if streaming_enabled: + streaming_work.append((i, req_key, codes_nq_t, finished)) + continue + if self._cuda_graph_wrapper is not None: out = self._cuda_graph_wrapper.decode(codes_nq_t) else: @@ -216,19 +355,246 @@ class MossTTSCodecDecoder(nn.Module): ) wav = wav[..., trim:] - audios[i] = wav.reshape(-1) if self._n_channels == 1 else wav + audios[i] = wav.reshape(-1) if wav.ndim == 1 or int(wav.shape[0]) == 1 else wav + + if streaming_work: + for i, wav in self._decode_streaming_batch(streaming_work).items(): + audios[i] = wav.reshape(-1) if wav.ndim == 1 or int(wav.shape[0]) == 1 else wav return OmniOutput( text_hidden_states=None, multimodal_outputs={"model_outputs": audios, "sr": srs}, ) + def _finish_empty_streaming_requests(self, info_list: list[dict[str, Any]]) -> dict[int, torch.Tensor]: + """Release codec stream state for empty finish sentinels. + + Stage-0 can finish on a step that emits no new audio frame. The stage + input processor forwards that as an empty payload with finished=true. + If a request never acquired a stream slot, do not offline-decode its + buffered codes here: streaming requests must only emit client deltas. + """ + session = self._stream_session + outputs: dict[int, torch.Tensor] = {} + if session is None: + return outputs + for i, info in enumerate(info_list): + if not isinstance(info, dict): + continue + meta = (info.get("meta", {}) or {}) if isinstance(info.get("meta", {}), dict) else {} + if not bool(meta.get("codec_streaming", False)): + continue + finished = bool(meta.get("stream_finished", meta.get("finished", False))) + if not finished: + continue + req_key = self._runtime_request_key(info, meta, i) + slot = self._stream_req_slots.get(req_key) + pending = req_key in self._stream_pending_codes + if slot is not None or pending: + if pending and slot is None: + logger.warning( + "MOSS codec stream request %s finished before a codec stream slot became available; " + "dropping buffered codes instead of offline-decoding a non-delta waveform.", + req_key, + ) + self._finish_stream_request(req_key, session, slot) + return outputs + @staticmethod - def _runtime_value(info: Any, meta: dict[str, Any], name: str, default: Any = None) -> Any: - if name in meta: - return meta[name] - if isinstance(info, dict) and name in info: - return info[name] + def _normalize_seq_token_counts(value: Any) -> list[int] | None: + if value is None: + return None + if not isinstance(value, (list, tuple)): + raise TypeError( + "MossTTS codec expects seq_token_counts to be a list/tuple of per-request token counts, " + f"got {type(value).__name__}." + ) + counts = [int(item) for item in value] + if not counts: + return None + for count in counts: + if count < 0: + raise ValueError(f"MossTTS codec seq_token_counts must be non-negative, got {counts}.") + return counts + + def _runtime_request_key(self, info: Any, meta: dict[str, Any], index: int) -> str: + for value in ( + meta.get("req_id"), + info.get("request_id") if isinstance(info, dict) else None, + ): + if isinstance(value, (list, tuple)): + value = value[0] if value else None + if value is not None: + return str(value) + return f"moss-codec-stream-{index}" + + def _empty_audio(self) -> torch.Tensor: + if self._n_channels > 1: + return torch.zeros((self._n_channels, 0), dtype=torch.float32) + return torch.zeros((0,), dtype=torch.float32) + + def _ensure_stream_session(self) -> _MossCodecStreamSession | None: + if self._codec is None: + return None + if self._stream_session is not None: + return self._stream_session + slots = self._stream_slots + if slots <= 0: + scheduler_cfg = getattr(self.vllm_config, "scheduler_config", None) + slots = int(getattr(scheduler_cfg, "max_num_seqs", 1) or 1) + self._stream_session = _MossCodecStreamSession( + self._codec, + stream_slots=max(1, slots), + n_vq=self._n_vq, + ) + return self._stream_session + + def _decode_streaming_batch( + self, + items: list[tuple[int, str, torch.Tensor, bool]], + ) -> dict[int, torch.Tensor]: + session = self._ensure_stream_session() + if session is None: + return {} + + outputs: dict[int, torch.Tensor] = {} + grouped: dict[int, list[tuple[int, str, int, torch.Tensor, bool]]] = {} + max_step_frames = max(1, int(self._stream_max_step_frames)) + + for output_index, request_id, codes_nq_t, finished in items: + pending = self._stream_pending_codes.get(request_id) + slot = self._stream_req_slots.get(request_id) + if slot is None: + slot = session.acquire() + if slot is None: + self._append_stream_pending(request_id, codes_nq_t) + if request_id not in self._stream_starved_reqs: + logger.warning( + "MOSS codec streaming slots exhausted; buffering %s until a stream slot is available.", + request_id, + ) + self._stream_starved_reqs.add(request_id) + if finished: + logger.warning( + "MOSS codec stream request %s finished before a codec stream slot became available; " + "dropping buffered codes instead of offline-decoding a non-delta waveform.", + request_id, + ) + self._finish_stream_request(request_id, session, None) + continue + self._stream_req_slots[request_id] = slot + + if pending: + self._append_stream_pending(request_id, codes_nq_t) + replay_codes = self._pop_stream_pending(request_id) + wav = self._decode_stream_slot_sequence(session, slot, replay_codes) + if wav is not None: + outputs[output_index] = wav + if finished: + self._finish_stream_request(request_id, session, slot) + continue + + if int(codes_nq_t.shape[1]) > max_step_frames: + wav = self._decode_stream_slot_sequence(session, slot, codes_nq_t) + if wav is not None: + outputs[output_index] = wav + if finished: + self._finish_stream_request(request_id, session, slot) + continue + + grouped.setdefault(int(codes_nq_t.shape[1]), []).append( + (output_index, request_id, slot, codes_nq_t, finished) + ) + + for group in grouped.values(): + plan = {slot: codes_nq_t for _, _, slot, codes_nq_t, _ in group} + decoded = session.step(plan) + for output_index, request_id, slot, _, finished in group: + wav = decoded.get(slot) + if wav is not None: + outputs[output_index] = wav + if finished: + self._finish_stream_request(request_id, session, slot) + + return outputs + + def _append_stream_pending(self, request_id: str, codes_nq_t: torch.Tensor) -> None: + self._stream_pending_codes.setdefault(request_id, []).append( + codes_nq_t.detach().to("cpu", torch.long).contiguous() + ) + + def _pop_stream_pending(self, request_id: str) -> torch.Tensor: + pending = self._stream_pending_codes.pop(request_id, []) + if not pending: + return torch.empty((self._n_vq, 0), dtype=torch.long) + return torch.cat(pending, dim=1).contiguous() + + def _decode_stream_slot_sequence( + self, + session: _MossCodecStreamSession, + slot: int, + codes_nq_t: torch.Tensor, + ) -> torch.Tensor | None: + if codes_nq_t.numel() == 0: + return None + max_step_frames = max(1, int(self._stream_max_step_frames)) + parts: list[torch.Tensor] = [] + for start in range(0, int(codes_nq_t.shape[1]), max_step_frames): + chunk = codes_nq_t[:, start : start + max_step_frames] + decoded = session.step({slot: chunk}) + wav = decoded.get(slot) + if wav is not None: + parts.append(wav) + if not parts: + return None + return torch.cat(parts, dim=-1) if len(parts) > 1 else parts[0] + + def _finish_stream_request( + self, + request_id: str, + session: _MossCodecStreamSession, + slot: int | None, + ) -> None: + if slot is not None: + session.release(slot) + self._stream_req_slots.pop(request_id, None) + self._stream_pending_codes.pop(request_id, None) + self._stream_starved_reqs.discard(request_id) + + def on_requests_finished(self, finished_req_ids: set[str] | list[str]) -> None: + """Release codec streaming slots when requests finish outside payload flow. + + Normal streaming completion releases slots from ``_decode_streaming_batch`` + when the Stage-0 payload carries ``finished=True``. Client disconnects + and engine-side aborts can finish a request without delivering that + terminal payload, so the runner calls this hook from its finished-request + path to avoid leaking stream slots and buffered codes. + """ + session = self._stream_session + for req_id in finished_req_ids: + request_id = str(req_id) + slot = self._stream_req_slots.get(request_id) + has_state = ( + slot is not None or request_id in self._stream_pending_codes or request_id in self._stream_starved_reqs + ) + if not has_state: + continue + if session is not None: + self._finish_stream_request(request_id, session, slot) + else: + self._stream_req_slots.pop(request_id, None) + self._stream_pending_codes.pop(request_id, None) + self._stream_starved_reqs.discard(request_id) + + def _connector_int(self, name: str, default: int = 0) -> int: + model_cfg = getattr(self.vllm_config, "model_config", None) + connector_cfg = getattr(model_cfg, "stage_connector_config", None) + if isinstance(connector_cfg, dict): + extra_cfg: dict | None = connector_cfg.get("extra", connector_cfg) + else: + extra_cfg = getattr(connector_cfg, "extra", None) + if isinstance(extra_cfg, dict) and name in extra_cfg: + return int(extra_cfg[name]) return default # ------------------------------------------------------------------ @@ -272,14 +638,12 @@ class MossTTSCodecDecoder(nn.Module): (".self_attn.out_projs.0.", ".attn.out_proj."), (".linear1.", ".ff1."), (".linear2.", ".ff2."), - # v2 (MOSS-Audio-Tokenizer-v2) uses different submodule names for - # the same attention/FFN sublayers — no trailing "s"/index on - # in_proj/out_proj, and an `ffn.{0,2}` Sequential instead of - # separate linear1/linear2 attributes. - (".self_attn.in_proj.", ".attn.in_proj."), - (".self_attn.out_proj.", ".attn.out_proj."), - (".ffn.0.", ".ff1."), - (".ffn.2.", ".ff2."), + # v2 checkpoint names use singular in_proj/out_proj and ffn.{0,2}; + # the vendored module keeps the original MOSS layer names. + (".self_attn.in_proj.", ".self_attn.in_projs.0."), + (".self_attn.out_proj.", ".self_attn.out_projs.0."), + (".ffn.0.", ".linear1."), + (".ffn.2.", ".linear2."), (".layer_scale_1.", ".ls1."), (".layer_scale_2.", ".ls2."), (".input_proj.", ".in_proj."), @@ -292,21 +656,39 @@ class MossTTSCodecDecoder(nn.Module): return name.replace(src, dst) return name - loaded_total = 0 + loaded_names: set[str] = set() skipped: list[str] = [] + shape_mismatches: list[tuple[str, str, tuple[int, ...], tuple[int, ...]]] = [] for name, tensor in codec_weights: # Try direct name first (e.g. ``quantizer.input_proj.*`` exists # under the same name in both layouts), then the remap (transformer # submodules need ``.linear1.``→``.ff1.`` etc.). tgt = name if name in params_dict else _remap(name) if tgt in params_dict: + expected_shape = tuple(params_dict[tgt].shape) + actual_shape = tuple(tensor.shape) + if expected_shape != actual_shape: + shape_mismatches.append((name, tgt, actual_shape, expected_shape)) + continue default_weight_loader(params_dict[tgt], tensor) - loaded_total += 1 + loaded_names.add(tgt) else: skipped.append(name) + + missing = sorted(set(params_dict) - loaded_names) + if missing or skipped or shape_mismatches: + raise RuntimeError( + "MOSS Audio Tokenizer weights were not fully loaded: " + f"loaded={len(loaded_names)}/{len(params_dict)} " + f"missing={len(missing)} skipped={len(skipped)} " + f"shape_mismatches={len(shape_mismatches)}; " + f"first_missing={missing[:5]} " + f"first_skipped={skipped[:5]} " + f"first_shape_mismatches={shape_mismatches[:3]}" + ) logger.info( "MOSS Audio Tokenizer weights: loaded=%d/%d skipped=%d (first skipped: %s)", - loaded_total, + len(loaded_names), len(params_dict), len(skipped), skipped[:3] if skipped else "none", @@ -316,7 +698,15 @@ class MossTTSCodecDecoder(nn.Module): codec.to(device=device, dtype=torch.float32) codec.eval() self._codec = codec - self._n_channels = int(getattr(codec_cfg, "number_channels", 1) or 1) + inferred_channels = 2 if "v2" in codec_path.lower() else 1 + self._n_channels = int( + getattr( + codec_cfg, + "number_channels", + getattr(codec_cfg, "num_channels", inferred_channels), + ) + or inferred_channels + ) self._sr_tensor = torch.tensor(int(codec_cfg.sampling_rate), dtype=torch.int32) logger.info( @@ -336,16 +726,13 @@ class MossTTSCodecDecoder(nn.Module): def _build_codec(self, codec_path: str) -> tuple[Any, nn.Module]: try: - from transformers import AutoConfig, AutoModel - - codec_cfg = AutoConfig.from_pretrained(codec_path, trust_remote_code=True) - codec = AutoModel.from_config(codec_cfg, trust_remote_code=True) - logger.info("Using MOSS Audio Tokenizer remote-code classes from %s", codec_path) + codec_cfg = MossAudioTokenizerV2Config.from_pretrained(codec_path) + codec = MossAudioTokenizerV2Model(codec_cfg) + logger.info("Using vendored MOSS Audio Tokenizer v2 classes from %s", codec_path) return codec_cfg, codec except Exception: logger.exception( - "Failed to instantiate official MOSS Audio Tokenizer via HF remote code; " - "falling back to vendored codec." + "Failed to instantiate vendored MOSS Audio Tokenizer v2; falling back to legacy vendored codec." ) codec_cfg = MossAudioTokenizerConfig.from_pretrained(codec_path) diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local.py index bf01a83bf..2fd0beefd 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local.py @@ -126,6 +126,7 @@ def _sample_token( top_k: int, top_p: float, do_sample: bool, + generator: torch.Generator | None = None, ) -> torch.Tensor: """Top-k + top-p sampling (matches upstream's ``sample_token`` for the inference branch). @@ -152,7 +153,7 @@ def _sample_token( probs = F.softmax(logits, dim=-1) flat = probs.reshape(-1, probs.shape[-1]) - sampled = torch.multinomial(flat, num_samples=1).reshape(probs.shape[:-1]) + sampled = torch.multinomial(flat, num_samples=1, generator=generator).reshape(probs.shape[:-1]) return sampled diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py index cb2e95551..e0d871b46 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py @@ -160,6 +160,7 @@ class MossTTSLocalDepthTransformer(nn.Module): text_top_p: float = 1.0, repetition_penalty: float = 1.0, history_per_codebook: list[list[int]] | None = None, + generator: torch.Generator | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: """Generate one audio frame for batch B. @@ -183,7 +184,16 @@ class MossTTSLocalDepthTransformer(nn.Module): local_hidden = hidden[:, 0, :] binary_logits = local_text_lm_head(local_hidden).float() - binary_choice = _sample_token(binary_logits, text_temperature, text_top_k, text_top_p, do_sample) + # This is a binary continue/stop gate. The checkpoint expects sampling + # here; greedy argmax is biased toward "continue" and may never stop. + binary_choice = _sample_token( + binary_logits, + text_temperature, + text_top_k, + text_top_p, + do_sample, + generator=generator, + ) should_continue = binary_choice.eq(0) import os as _os @@ -209,7 +219,14 @@ class MossTTSLocalDepthTransformer(nn.Module): pos = sel > 0 sel = torch.where(pos, sel / repetition_penalty, sel * repetition_penalty) channel_logits.index_copy_(-1, hist_t, sel) - channel_token = _sample_token(channel_logits, temperature, top_k, top_p, do_sample) + channel_token = _sample_token( + channel_logits, + temperature, + top_k, + top_p, + do_sample, + generator=generator, + ) codes[:, channel_index] = channel_token if channel_index + 1 < n_vq: diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py index 9f4037e4b..9758c922e 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py @@ -1139,26 +1139,19 @@ class MossTTSRealtimeTalkerForGeneration(nn.Module): info["audio_codes"] = {"current": new_codes, "accumulated": updated_acc} state["step"] = int(state.get("step", 0)) + 1 - per_req_codes[i] = updated_acc + per_req_codes[i] = new_codes.unsqueeze(0) have_codes = True if not have_codes: return OmniOutput(text_hidden_states=hidden, multimodal_outputs={}) - # Emit one accumulated-codes tensor per request as a list; the splitter - # routes ``element[idx]`` to request idx, so each Stage-1 request decodes - # its own codes instead of request 0's. - # The realtime variant emits raw codes (no delay pattern), so we signal - # that to the chunk processor via a 1-D bool tensor in ``meta.finished`` - # (already a tensor field on the schema). The processor reads its truth - # value as "skip de-delay" only for realtime; the delay path doesn't - # populate ``meta`` at all. - device = hidden.device + # Emit one current-frame tensor per request as a list; the splitter + # routes ``element[idx]`` to request idx, so each Stage-1 request receives + # only its own newly generated raw codec row. return OmniOutput( text_hidden_states=hidden, multimodal_outputs={ "codes": {"audio": per_req_codes}, - "meta": {"finished": torch.tensor([True], dtype=torch.bool, device=device)}, }, ) @@ -1266,7 +1259,7 @@ class MossTTSLocalTalkerForGeneration(nn.Module): * Per-step audio generation runs ``MossTTSLocalDepthTransformer`` (1-layer GPT2-style local transformer, interleaved RoPE, ``n_vq=12`` codebooks -- structurally different from Realtime's Qwen3-style - ``CodePredictorBaseModel``) inside ``make_omni_output``. Stop + ``CodePredictorBaseModel``) in the runner-owned ``talker_mtp`` path. Stop condition: the local transformer's binary head selects the "audio_end" candidate (logits index 1). """ @@ -1327,6 +1320,8 @@ class MossTTSLocalTalkerForGeneration(nn.Module): # Stacked embedding cache built after load_weights() for vectorised # additive-fusion gathers (avoids an n_vq-iteration Python loop). self._stacked_audio_emb_w: torch.Tensor | None = None + self.mtp_hidden_size = hidden_size + self.talker_mtp_accepts_req_infos = True self.gpu_resident_buffer_keys: set[tuple[str, str]] = { ("audio_codes", "current"), @@ -1384,6 +1379,15 @@ class MossTTSLocalTalkerForGeneration(nn.Module): num_rows, ): if state is not None and state.get("is_stopping"): + forced_im_end_count = int(state.get("_forced_im_end_count", 0)) + if forced_im_end_count == 1: + logger.warning( + "MOSS-TTS Local request is still running after " + "im_end_token_id=%d was forced once; verify stage-0 " + "stop_token_ids/eos includes this id.", + self.im_end_token_id, + ) + state["_forced_im_end_count"] = forced_im_end_count + 1 logits[row_start:row_end, self.im_end_token_id] = 0.0 else: logits[row_start:row_end, self.audio_assistant_slot_token_id] = 0.0 @@ -1455,38 +1459,159 @@ class MossTTSLocalTalkerForGeneration(nn.Module): else torch.full((self.n_vq,), self.audio_pad_token_id, dtype=torch.long, device=device) ) - max_new_frames_req = info_dict.get("max_new_frames") - if isinstance(max_new_frames_req, (list, tuple)) and max_new_frames_req: - max_new_frames_req = max_new_frames_req[0] - try: - max_new_frames = int(max_new_frames_req) if max_new_frames_req is not None else -1 - except (TypeError, ValueError): - max_new_frames = -1 + max_new_frames = info_dict.get("max_new_frames", [-1])[0] info_update: dict[str, Any] = { - "audio_state": {"is_stopping": False, "step": 0, "max_new_frames": max_new_frames}, - "audio_codes": {"current": current_codes}, + "audio_state": { + "is_stopping": False, + "step": 0, + "max_new_frames": max_new_frames, + }, + "audio_codes": {"current": current_codes, "emit": False}, "ref_offset": ref_offset + span_len, } return input_ids, embeds, info_update - # Decode step: input_ids is the forced text token from compute_logits. + # Decode step: return the plain forced text-token embedding. The local + # depth transformer runs in OmniGPUModelRunner.talker_mtp before the + # backbone forward; it adds the freshly sampled audio-code embedding + # and stores the generated code frame in model_intermediate_buffer. text_embed = self.model.embed_tokens(input_ids.reshape(1)) # (1, H) - prev_codes = (info_dict.get("audio_codes", {}) or {}).get("current") - if isinstance(prev_codes, torch.Tensor) and prev_codes.numel() == self.n_vq: - codes = prev_codes.to(device=device, dtype=torch.long).unsqueeze(0) # (1, n_vq) - combined = text_embed + self._audio_embed(codes) + hs = info_dict.get("hidden_states", {}) or {} + last_hidden = hs.get("last") + if isinstance(last_hidden, torch.Tensor) and last_hidden.numel() > 0: + mtp_hidden = last_hidden.to(device=device, dtype=text_embed.dtype).reshape(1, -1) else: - combined = text_embed - return input_ids, combined, {} + mtp_hidden = torch.zeros((1, self.hidden_size), device=device, dtype=text_embed.dtype) + return input_ids, text_embed, {"mtp_inputs": (mtp_hidden, torch.zeros_like(mtp_hidden))} def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]: if hidden_states.numel() == 0: return {} return {"hidden_states": {"last": hidden_states[-1].detach()}} + @torch.inference_mode() + def talker_mtp( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor, + last_talker_hidden: torch.Tensor, + text_step: torch.Tensor, + do_sample: bool | None = None, + temperature: float | None = None, + top_k: int | None = None, + top_p: float | None = None, + generator: torch.Generator | None = None, + req_infos: list[dict[str, Any]] | None = None, + **_: Any, + ) -> tuple[torch.Tensor, None]: + bsz = int(input_embeds.shape[0]) + dev = input_embeds.device + input_embeds_out = input_embeds.reshape(bsz, -1).clone() + req_infos = req_infos or [{} for _ in range(bsz)] + + def write_update(info: dict[str, Any], update: dict[str, Any]) -> None: + for key, value in update.items(): + if isinstance(value, dict): + target = info.setdefault(key, {}) + target.update(value) + else: + info[key] = value + + # Match MOSS-TTS Local v1.5's model-card/SGLang defaults. The generic + # stage SamplingParams are for the backbone token loop and should not + # silently retune the local depth transformer's audio-channel sampler. + audio_temperature = 1.7 + audio_top_k = 25 + audio_top_p = 0.8 + do_sample_val = True if do_sample is None else bool(do_sample) + + for i in range(bsz): + info = req_infos[i] if i < len(req_infos) and isinstance(req_infos[i], dict) else {} + state = dict(info.get("audio_state", {}) or {}) + step = int(state.get("step", 0)) + max_new_frames = int(state.get("max_new_frames", -1)) + acc = (info.get("audio_codes", {}) or {}).get("accumulated") + + if state.get("is_stopping"): + write_update( + info, + { + "audio_state": state, + "audio_codes": { + "current": input_embeds.new_empty((0, self.n_vq), dtype=torch.long), + "emit": False, + }, + }, + ) + continue + + rep_window = 50 + if isinstance(acc, torch.Tensor) and acc.numel() > 0: + tail = acc[-rep_window:].long().cpu().tolist() + hist_per_cb = [[row[cb] for row in tail] for cb in range(self.n_vq)] + else: + hist_per_cb = [[] for _ in range(self.n_vq)] + + should_continue_t, new_codes_b = self.local_transformer.generate_frame( + last_talker_hidden[i : i + 1], + self.audio_lm_heads, + self.audio_embeddings, + self.local_text_lm_head, + n_vq=self.n_vq, + do_sample=do_sample_val, + temperature=audio_temperature, + top_k=audio_top_k, + top_p=audio_top_p, + repetition_penalty=1.0, + history_per_codebook=hist_per_cb, + generator=generator, + ) + should_continue = bool(should_continue_t[0].item()) + new_codes = new_codes_b.squeeze(0).to(device=dev, dtype=torch.long) + + if not should_continue or (0 <= max_new_frames <= step): + state["is_stopping"] = True + state["step"] = step + 1 + write_update( + info, + { + "audio_state": state, + "audio_codes": { + "current": input_embeds.new_empty((0, self.n_vq), dtype=torch.long), + "accumulated": acc, + "emit": False, + }, + }, + ) + continue + + if isinstance(acc, torch.Tensor) and acc.numel() > 0: + updated_acc = torch.cat([acc.to(dev), new_codes.unsqueeze(0)], dim=0) + else: + updated_acc = new_codes.unsqueeze(0) + + state["step"] = step + 1 + current = new_codes.unsqueeze(0) + input_embeds_out[i : i + 1] = input_embeds_out[i : i + 1] + self._audio_embed(current).to( + dtype=input_embeds_out.dtype + ) + write_update( + info, + { + "audio_state": state, + "audio_codes": { + "current": current, + "accumulated": updated_acc, + "emit": True, + }, + }, + ) + + return input_embeds_out, None + # ------------------------------------------------------------------ - # Per-step audio generation via local transformer + # Package runner-generated audio frames # ------------------------------------------------------------------ def make_omni_output( @@ -1511,91 +1636,41 @@ class MossTTSLocalTalkerForGeneration(nn.Module): self._batch_state = [(info["audio_state"] if isinstance(info, dict) else {}) for info in info_dicts] self._batch_state_spans = kwargs.get("request_token_spans") - spans = kwargs.get("request_token_spans") - per_req_codes: list[torch.Tensor] = [hidden.new_empty((0, self.n_vq), dtype=torch.long) for _ in info_dicts] + per_req_codes: list[torch.Tensor] = [] have_codes = False - - if hidden.numel() > 0 and info_dicts: - num_rows = hidden.shape[0] - if spans is None or len(spans) != len(info_dicts): - raise RuntimeError( - "MOSS-TTS make_omni_output requires request_token_spans " - f"for {len(info_dicts)} request infos, got {0 if spans is None else len(spans)}" - ) - for i, info in enumerate(info_dicts): - if not isinstance(info, dict): - continue - row_start, row_end = spans[i] - row_end = min(int(row_end), num_rows) - if row_start >= row_end: - continue - - state = info.get("audio_state", {}) or {} - if state.get("is_stopping"): - continue # already stopped -- no more audio frames - - last_h = hidden[row_end - 1].unsqueeze(0) # (1, H) - - # Sampling parameters mirror Realtime's defaults; repetition - # penalty applies over a 50-frame rolling window per codebook. - rep_window = 50 - acc_for_hist = (info.get("audio_codes", {}) or {}).get("accumulated") - if isinstance(acc_for_hist, torch.Tensor) and acc_for_hist.numel() > 0: - tail = acc_for_hist[-rep_window:].long().cpu().tolist() - hist_per_cb = [[row[cb] for row in tail] for cb in range(self.n_vq)] - else: - hist_per_cb = [[] for _ in range(self.n_vq)] - - should_continue_t, new_codes = self.local_transformer.generate_frame( - last_h, - self.audio_lm_heads, - self.audio_embeddings, - self.local_text_lm_head, - n_vq=self.n_vq, - do_sample=True, - temperature=0.8, - top_k=30, - top_p=0.6, - repetition_penalty=1.1, - history_per_codebook=hist_per_cb, - ) - should_continue = bool(should_continue_t[0].item()) - new_codes = new_codes.squeeze(0) # (n_vq,) - - step = int(state.get("step", 0)) - max_new_frames = int(state.get("max_new_frames", -1)) - if not should_continue or (0 <= max_new_frames <= step): - state["is_stopping"] = True - state["step"] = step + 1 - info["audio_codes"] = { - "current": new_codes, - "accumulated": (info.get("audio_codes", {}) or {}).get("accumulated"), - } - continue # don't append the stop frame to accumulated - - acc = (info.get("audio_codes", {}) or {}).get("accumulated") - if isinstance(acc, torch.Tensor) and acc.numel() > 0: - updated_acc = torch.cat([acc.to(new_codes.device), new_codes.unsqueeze(0)], dim=0) - else: - updated_acc = new_codes.unsqueeze(0) - - info["audio_codes"] = {"current": new_codes, "accumulated": updated_acc} - state["step"] = step + 1 - per_req_codes[i] = updated_acc + for info in info_dicts: + current = (info.get("audio_codes", {}) or {}).get("current") if isinstance(info, dict) else None + emit = bool((info.get("audio_codes", {}) or {}).get("emit")) if isinstance(info, dict) else False + if emit and isinstance(current, torch.Tensor) and current.numel() > 0: + codes = current.to(device=hidden.device, dtype=torch.long) + if codes.dim() == 1: + codes = codes.unsqueeze(0) + per_req_codes.append(codes) have_codes = True + else: + per_req_codes.append(hidden.new_empty((0, self.n_vq), dtype=torch.long)) if not have_codes: return OmniOutput(text_hidden_states=hidden, multimodal_outputs={}) - # Local-v1.5 emits raw codes (no delay pattern), so signal that to the - # chunk processor the same way Realtime does, via ``meta.finished`` - # (read as "skip de-delay" by ``talker2codec_async_chunk``). - device = hidden.device + # Local-v1.5 emits the current raw code frame (no delay pattern); the + # pipeline routes those rows through ``talker2codec_raw_async_chunk``. + per_req_ref_codes: list[torch.Tensor] = [] + for info in info_dicts: + ref_codes = (info.get("codes", {}) or {}).get("ref") if isinstance(info, dict) else None + if isinstance(ref_codes, torch.Tensor) and ref_codes.numel() > 0: + ref_codes = ref_codes.detach() + if ref_codes.dim() == 1 and ref_codes.numel() % self.n_vq == 0: + ref_codes = ref_codes.view(-1, self.n_vq) + if ref_codes.dim() == 2 and ref_codes.shape[1] == self.n_vq: + per_req_ref_codes.append(ref_codes.to(device=hidden.device, dtype=torch.long)) + continue + per_req_ref_codes.append(hidden.new_empty((0, self.n_vq), dtype=torch.long)) + return OmniOutput( text_hidden_states=hidden, multimodal_outputs={ - "codes": {"audio": per_req_codes}, - "meta": {"finished": torch.tensor([True], dtype=torch.bool, device=device)}, + "codes": {"audio": per_req_codes, "ref": per_req_ref_codes}, }, ) diff --git a/vllm_omni/model_executor/models/moss_tts/pipeline.py b/vllm_omni/model_executor/models/moss_tts/pipeline.py index d0cec08e5..c6cf5c86c 100644 --- a/vllm_omni/model_executor/models/moss_tts/pipeline.py +++ b/vllm_omni/model_executor/models/moss_tts/pipeline.py @@ -31,7 +31,7 @@ MOSS_TTS_PIPELINE = PipelineConfig( input_sources=(), owns_tokenizer=True, engine_output_type="latent", - async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_async_chunk"), + async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_delay_async_chunk"), sampling_constraints={ "detokenize": False, }, @@ -62,7 +62,7 @@ MOSS_TTS_REALTIME_PIPELINE = PipelineConfig( input_sources=(), owns_tokenizer=True, engine_output_type="latent", - async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_async_chunk"), + async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_raw_async_chunk"), sampling_constraints={"detokenize": False}, ), StagePipelineConfig( @@ -91,8 +91,11 @@ MOSS_TTS_LOCAL_PIPELINE = PipelineConfig( input_sources=(), owns_tokenizer=True, engine_output_type="latent", - async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_async_chunk"), - sampling_constraints={"detokenize": False}, + async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2codec_raw_async_chunk"), + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [151645], + }, ), StagePipelineConfig( stage_id=1, diff --git a/vllm_omni/model_executor/stage_input_processors/moss_tts.py b/vllm_omni/model_executor/stage_input_processors/moss_tts.py index 48c0f98cb..3dfe2a603 100644 --- a/vllm_omni/model_executor/stage_input_processors/moss_tts.py +++ b/vllm_omni/model_executor/stage_input_processors/moss_tts.py @@ -5,6 +5,8 @@ from __future__ import annotations +from collections import defaultdict +from collections.abc import Mapping from typing import Any import torch @@ -15,6 +17,8 @@ from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayloadStruct logger = init_logger(__name__) +_MOSS_AUDIO_PAD_CODE = 1024 + # --------------------------------------------------------------------------- # Helpers @@ -90,13 +94,13 @@ def talker2codec( # --------------------------------------------------------------------------- -def talker2codec_async_chunk( +def talker2codec_delay_async_chunk( transfer_manager: Any, multimodal_output: dict[str, Any] | None, request: Any, is_finished: bool = False, ) -> OmniPayloadStruct | None: - """Emit accumulated audio codes to Stage 1 as they arrive from Stage 0. + """Emit delay-patterned accumulated audio codes to Stage 1. State is maintained in ``transfer_manager`` keyed by request ID. A chunk is forwarded to Stage 1 when either: @@ -138,14 +142,6 @@ def talker2codec_async_chunk( req_state["accumulated"] = new_rows else: req_state["accumulated"] = torch.cat([req_state["accumulated"], new_rows], dim=0) - # Realtime variant emits raw (un-delay-patterned) codes; record that - # so the emit step below skips the apply_de_delay_pattern transform. - # Realtime sets ``meta.finished`` to a 1-D bool tensor; the delay - # variant leaves ``meta`` empty. - meta_in = pooling_output.get("meta", {}) or {} - flag = meta_in.get("finished") - if isinstance(flag, torch.Tensor) and flag.numel() >= 1 and bool(flag.reshape(-1)[0].item()): - req_state["skip_dedelay"] = True acc = req_state["accumulated"] if acc is None or acc.numel() == 0: @@ -181,15 +177,12 @@ def talker2codec_async_chunk( # 1. de-delay ``(T+nq-1, nq)`` → ``(T, nq)`` # 2. drop rows that are entirely pad (separators between audio segments, # and the leading text-mode rows that precede the first audio_start). - # The realtime talker emits raw codes (no delay), so steps 1 is skipped. chunk_codes_long = chunk_codes.to(torch.long).cpu().contiguous() # (T_chunk, NQ) nq = int(chunk_codes_long.shape[1]) t_chunk = int(chunk_codes_long.shape[0]) audio_pad_code = 1024 # MOSS-TTS audio_pad_code; same value across variants. - if req_state.get("skip_dedelay"): - de_delayed = chunk_codes_long - elif t_chunk > nq: + if t_chunk > nq: de_delayed = chunk_codes_long.new_zeros((t_chunk - nq + 1, nq)) for i in range(nq): de_delayed[:, i] = chunk_codes_long[i : i + de_delayed.shape[0], i] @@ -224,9 +217,133 @@ def talker2codec_async_chunk( codes=CodesStruct(audio=codec_flat), meta=MetaStruct( left_context_size=left_context, - finished=torch.tensor(is_finished, dtype=torch.bool), + finished=torch.tensor(bool(is_finished), dtype=torch.bool), ), ) -__all__ = ["talker2codec", "talker2codec_async_chunk"] +def talker2codec_raw_async_chunk( + transfer_manager: Any, + multimodal_output: dict[str, Any] | None, + request: Any, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Async processor for MOSS-TTS Local/Realtime raw codec rows. + + Stage 0 emits newly generated raw codec rows shaped ``[T, n_vq]`` (normally + ``[1, n_vq]`` per decode step). This processor buffers those new rows until + a codec chunk is ready, then forwards the chunk to Stage 1. No delay-pattern + de-delay is applied on this path. + """ + external_req_id = getattr(request, "external_req_id", None) + req_id = str(external_req_id if external_req_id is not None else getattr(request, "request_id", id(request))) + + if not hasattr(transfer_manager, "code_prompt_token_ids"): + transfer_manager.code_prompt_token_ids = defaultdict(list) + if not hasattr(transfer_manager, "request_payload"): + transfer_manager.request_payload = {} + if not hasattr(transfer_manager, "put_req_chunk"): + transfer_manager.put_req_chunk = defaultdict(int) + + pending_frames = transfer_manager.code_prompt_token_ids[req_id] + + if isinstance(multimodal_output, Mapping): + codes_dict = multimodal_output.get("codes", {}) or {} + new_frames = codes_dict.get("audio") + if isinstance(new_frames, torch.Tensor) and new_frames.numel() > 0: + frames_cpu = new_frames.detach().to("cpu", torch.long).contiguous() + if frames_cpu.ndim == 1: + frames_cpu = frames_cpu.reshape(1, -1) + if frames_cpu.ndim != 2: + raise ValueError(f"MOSS raw codec frames must be 2-D, got {tuple(frames_cpu.shape)}") + valid_rows = frames_cpu.ne(_MOSS_AUDIO_PAD_CODE).any(dim=1) + for frame in frames_cpu[valid_rows]: + pending_frames.append(frame.clone()) + # Raw/local streaming should mirror the non-streaming path: the codec + # decodes only generated audio rows. Reference audio conditions the + # talker, but feeding its codes into the codec streaming state adds a + # long first-packet prime step and changes the decoder state relative + # to non-streaming output. + + 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 {} + cfg = cfg if isinstance(cfg, dict) else {} + chunk_frames = int(cfg.get("codec_chunk_frames", 15) or 15) + initial_chunk_frames = int(cfg.get("initial_codec_chunk_frames") or 0) + if chunk_frames <= 0: + raise ValueError(f"codec_chunk_frames must be positive for MOSS raw streaming, got {chunk_frames}") + if initial_chunk_frames < 0: + raise ValueError(f"initial_codec_chunk_frames must be non-negative, got {initial_chunk_frames}") + if initial_chunk_frames > chunk_frames: + logger.warning( + "initial_codec_chunk_frames=%d > codec_chunk_frames=%d, clamping.", + initial_chunk_frames, + chunk_frames, + ) + initial_chunk_frames = chunk_frames + + pending = len(pending_frames) + emitted_any = int(transfer_manager.put_req_chunk.get(req_id, 0)) > 0 + threshold = initial_chunk_frames if initial_chunk_frames > 0 and not emitted_any else chunk_frames + if pending <= 0: + if is_finished: + transfer_manager.code_prompt_token_ids.pop(req_id, None) + transfer_manager.request_payload.pop(req_id, None) + return OmniPayloadStruct( + # A non-empty sentinel is required so Stage-1 is scheduled. + # ``code_flat_numel=0`` tells the codec this is a control-only + # finish packet, not an audio code. + codes=CodesStruct(audio=torch.tensor([0], dtype=torch.long)), + meta=MetaStruct( + req_id=[req_id], + left_context_size=0, + codec_streaming=True, + codec_chunk_frames=0, + codec_left_context_frames=0, + code_flat_numel=0, + stream_finished=torch.tensor(True, dtype=torch.bool), + finished=torch.tensor(True, dtype=torch.bool), + ), + request_id=req_id, + ) + return None + if not is_finished and pending < threshold: + return None + + emit_frames = pending if is_finished else threshold + chunk_rows = pending_frames[:emit_frames] + del pending_frames[:emit_frames] + chunk_codes = torch.stack( + [row.to(torch.long).cpu() for row in chunk_rows], + dim=0, + ).contiguous() + finished = bool(is_finished and len(pending_frames) == 0) + + codec_flat = chunk_codes.transpose(0, 1).contiguous().reshape(-1).to(torch.long) + + if finished: + transfer_manager.code_prompt_token_ids.pop(req_id, None) + transfer_manager.request_payload.pop(req_id, None) + + return OmniPayloadStruct( + codes=CodesStruct(audio=codec_flat), + meta=MetaStruct( + req_id=[req_id], + left_context_size=0, + codec_streaming=True, + codec_chunk_frames=int(chunk_codes.shape[0]), + codec_left_context_frames=0, + code_flat_numel=int(codec_flat.numel()), + stream_finished=torch.tensor(finished, dtype=torch.bool), + finished=torch.tensor(finished, dtype=torch.bool), + ), + request_id=req_id, + ) + + +__all__ = [ + "talker2codec", + "talker2codec_delay_async_chunk", + "talker2codec_raw_async_chunk", +] diff --git a/vllm_omni/worker/gpu_generation_model_runner.py b/vllm_omni/worker/gpu_generation_model_runner.py index 172121c14..c8fe92eba 100644 --- a/vllm_omni/worker/gpu_generation_model_runner.py +++ b/vllm_omni/worker/gpu_generation_model_runner.py @@ -76,6 +76,10 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin def _update_request_states(self, scheduler_output: SchedulerOutput): # remove requests + # Some stateful vocoder model may need to clean the state + # to avoid the leak of slots when the requests have been aborted. + if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"): + self.model.on_requests_finished(scheduler_output.finished_req_ids) for req_id in scheduler_output.finished_req_ids: self.input_batch.remove_request(req_id) scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys() @@ -789,6 +793,10 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin if hasattr(self.model, "get_dummy_runtime_additional_information"): runtime_addi = self.model.get_dummy_runtime_additional_information(num_reqs) model_kwargs["runtime_additional_information"] = runtime_addi + # Generation-stage code2wav models split flat input_ids using this + # shared contract. Dummy/profile runs use padded synthetic inputs, + # so expose one synthetic segment covering the actual tensor length. + model_kwargs["seq_token_counts"] = [int(num_tokens_padded)] if self.uses_mrope: positions = self.mrope_positions.gpu[:, :num_tokens_padded] diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index a283039f3..e40c220b8 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -1821,7 +1821,9 @@ class OmniGPUModelRunner(GPUModelRunner): def _explicit_talker_seed(req_id: str) -> int | None: sampling_params = getattr(self.requests[req_id], "sampling_params", None) extra_args = getattr(sampling_params, "extra_args", None) if sampling_params is not None else None - seed = extra_args.get("qwen3_tts_request_seed") if isinstance(extra_args, dict) else None + seed = None + if isinstance(extra_args, dict): + seed = extra_args.get("tts_local_seed") return int(seed) if seed is not None else None if decode_batch_size > 1 and any(_explicit_talker_seed(req_id) is not None for req_id in decode_req_ids): @@ -1869,6 +1871,11 @@ class OmniGPUModelRunner(GPUModelRunner): } if generator is not None: talker_kwargs["generator"] = generator + if getattr(self.model, "talker_mtp_accepts_req_infos", False): + talker_kwargs["req_ids"] = decode_req_ids + talker_kwargs["req_infos"] = [ + self.model_intermediate_buffer.setdefault(req_id, {}) for req_id in decode_req_ids + ] with current_omni_platform.set_forward_context( None, self.vllm_config, cudagraph_runtime_mode=_cudagraph_mode, batch_descriptor=batch_desc ): @@ -1888,8 +1895,9 @@ class OmniGPUModelRunner(GPUModelRunner): 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]}} - self._merge_additional_information_update(req_id, update_dict) + if code_predictor_codes is not None: + update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}} + self._merge_additional_information_update(req_id, update_dict) def _model_forward( self, From 991fb7408b9e68450c18b835f9f9a4c355a63475 Mon Sep 17 00:00:00 2001 From: LHXuuu Date: Fri, 3 Jul 2026 18:15:03 +0800 Subject: [PATCH 015/101] [BugFix][Ming-TTS] Improve Seed-TTS WER accuracy (#4859) Signed-off-by: LHXuuu --- .../benchmarks/data_modules/seed_tts_eval.py | 20 +++++++++- vllm_omni/benchmarks/patch/patch.py | 37 ++----------------- .../entrypoints/openai/serving_speech.py | 5 --- vllm_omni/metrics/definitions.py | 29 +++++++++++++++ .../models/common/ming/audio_vae.py | 9 +++-- .../models/ming_tts/patch_emission.py | 4 +- 6 files changed, 59 insertions(+), 45 deletions(-) diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py index efa312dec..70ed7cfef 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py @@ -39,6 +39,8 @@ Env: ``SEED_TTS_EVAL_DEVICE`` (e.g. ``cuda:0``, ``cpu``); ``SEED_TTS_HF_WHISPER_ defaults to ``openai/whisper-large-v3`` (override for debugging only). Set ``SEED_TTS_WER_SAVE_AUDIO_DIR`` to save the captured 24 kHz mono WAV used by WER evaluation for each synthesized utterance. +Streaming PCM is decoded using ``VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE`` / +``VLLM_OMNI_BENCH_AUDIO_CHANNELS`` (default: 24 kHz mono). """ from __future__ import annotations @@ -59,6 +61,7 @@ import numpy as np from vllm.benchmarks.datasets import SampleRequest from vllm_omni.benchmarks.data_modules.seed_tts_dataset import SeedTTSSampleRequest +from vllm_omni.metrics.definitions import stream_pcm_format_from_env logger = logging.getLogger(__name__) @@ -173,12 +176,27 @@ def process_one_official(hypo: str, truth: str, lang: str) -> tuple[float, str, return wer, raw_truth, raw_hypo -def _pcm_s16le_to_f32_16k(pcm: bytes, pcm_sample_rate: int = 24000) -> np.ndarray: +def _pcm_s16le_to_f32_16k( + pcm: bytes, + pcm_sample_rate: int | None = None, + channels: int | None = None, +) -> np.ndarray: import scipy.signal if not pcm: return np.zeros(0, dtype=np.float32) + if pcm_sample_rate is None or channels is None: + env_sample_rate, env_channels = stream_pcm_format_from_env() + if pcm_sample_rate is None: + pcm_sample_rate = env_sample_rate + if channels is None: + channels = env_channels raw = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0 + if channels > 1: + usable = (len(raw) // channels) * channels + if usable <= 0: + return np.zeros(0, dtype=np.float32) + raw = raw[:usable].reshape(-1, channels).mean(axis=1) target_len = int(len(raw) * 16000 / pcm_sample_rate) if target_len <= 0: return np.zeros(0, dtype=np.float32) diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index d6e28319c..a4eb683e6 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -35,8 +35,6 @@ from vllm.benchmarks.lib.endpoint_request_func import ( from vllm.logger import init_logger from vllm.tokenizers import TokenizerLike -logger = init_logger(__name__) - from vllm_omni.benchmarks.audio_continuity import compute_continuity_stats from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniDataset, DailyOmniSampleRequest from vllm_omni.benchmarks.data_modules.random_multi_modal_dataset import OmniRandomMultiModalDataset @@ -51,9 +49,9 @@ from vllm_omni.benchmarks.data_modules.sound_effect_dataset import SoundEffectDa from vllm_omni.benchmarks.data_modules.ttsd_dataset import TTSDDataset from vllm_omni.metrics import definitions as defs +logger = init_logger(__name__) + _AUDIO_CONTINUITY_THRESHOLD_ENV = "VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S" -_AUDIO_SAMPLE_RATE_ENV = "VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE" -_AUDIO_CHANNELS_ENV = "VLLM_OMNI_BENCH_AUDIO_CHANNELS" RETURN_STAGE_METRICS_FIELD = "return_stage_metrics" _IMAGE_STAGE_METRICS_BACKENDS = frozenset({"openai-image-edits-omni"}) _PRINT_STAGE = False @@ -111,32 +109,6 @@ def _audio_continuity_threshold_s() -> float: return max(value, 0.0) -def _audio_pcm_format() -> tuple[int, int]: - """Return ``(sample_rate, channels)`` of the streamed PCM response. - - Defaults to 24 kHz mono (Qwen3-TTS / VoxCPM2 / most MOSS-TTS variants). - Override for models with a different codec output format (e.g. - MOSS-TTS-Local-Transformer-v1.5, which streams 48 kHz stereo PCM) via - ``VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE`` / ``VLLM_OMNI_BENCH_AUDIO_CHANNELS`` - - getting this wrong silently skews audio_duration/audio_rtf/underrun stats. - """ - sample_rate = 24000 - channels = 1 - raw_sr = os.environ.get(_AUDIO_SAMPLE_RATE_ENV) - if raw_sr: - try: - sample_rate = int(raw_sr) - except ValueError: - logger.warning("Invalid %s=%r; using default %d", _AUDIO_SAMPLE_RATE_ENV, raw_sr, sample_rate) - raw_ch = os.environ.get(_AUDIO_CHANNELS_ENV) - if raw_ch: - try: - channels = int(raw_ch) - except ValueError: - logger.warning("Invalid %s=%r; using default %d", _AUDIO_CHANNELS_ENV, raw_ch, channels) - return sample_rate, channels - - def _pcm_s16le_to_seed_tts_wer_bytes( pcm_bytes: bytes, *, @@ -1187,9 +1159,8 @@ async def async_request_openai_audio_speech( output = MixRequestFuncOutput() output.prompt_len = request_func_input.prompt_len - # PCM format: 16-bit signed; sample_rate/channels are model-dependent - # (see _audio_pcm_format docstring). - sample_rate, channels = _audio_pcm_format() + # PCM format: 16-bit signed; sample_rate/channels are model-dependent. + sample_rate, channels = defs.stream_pcm_format_from_env() sample_width = 2 # 16-bit = 2 bytes st = time.perf_counter() diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 78bb0ab2c..72654738f 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -2620,16 +2620,12 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): 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 @@ -2652,7 +2648,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): """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, ) diff --git a/vllm_omni/metrics/definitions.py b/vllm_omni/metrics/definitions.py index 3b7e6e8d8..9660e4f34 100644 --- a/vllm_omni/metrics/definitions.py +++ b/vllm_omni/metrics/definitions.py @@ -11,11 +11,14 @@ time-bearing metrics use the ``_s`` suffix (values in seconds), counters use ``_total`` (auto-suffixed by the prometheus client), sizes use ``_bytes``. """ +import logging +import os from typing import Any # vllm_omni: namespace for omni-specific Prometheus families, distinct from # the upstream vllm:* families. METRIC_PREFIX = "vllm_omni:" +logger = logging.getLogger(__name__) # ============================================================================ @@ -280,6 +283,9 @@ def compute_denoise_step_latency(stage_gen_time: float, num_inference_steps: int # ming_flash 16000 — these models populate multimodal_output["audio_sample_rate"] # at runtime so this default only kicks in when the field is missing. DEFAULT_AUDIO_SAMPLE_RATE = 24000 +DEFAULT_AUDIO_CHANNELS = 1 +AUDIO_SAMPLE_RATE_ENV = "VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE" +AUDIO_CHANNELS_ENV = "VLLM_OMNI_BENCH_AUDIO_CHANNELS" _SAMPLE_RATE_KEYS = ("output_sample_rate", "audio_sample_rate", "sample_rate", "sampling_rate", "sr") @@ -306,3 +312,26 @@ def resolve_audio_sample_rate(source: dict[str, Any] | Any | None) -> int: if value > 0: return value return DEFAULT_AUDIO_SAMPLE_RATE + + +def stream_pcm_format_from_env( + *, + default_sample_rate: int = DEFAULT_AUDIO_SAMPLE_RATE, + default_channels: int = DEFAULT_AUDIO_CHANNELS, +) -> tuple[int, int]: + """Return the sample rate and channel count for streamed raw PCM.""" + sample_rate = default_sample_rate + channels = default_channels + raw_sr = os.environ.get(AUDIO_SAMPLE_RATE_ENV) + if raw_sr: + try: + sample_rate = int(raw_sr) + except ValueError: + logger.warning("Invalid %s=%r; using default %d", AUDIO_SAMPLE_RATE_ENV, raw_sr, sample_rate) + raw_ch = os.environ.get(AUDIO_CHANNELS_ENV) + if raw_ch: + try: + channels = int(raw_ch) + except ValueError: + logger.warning("Invalid %s=%r; using default %d", AUDIO_CHANNELS_ENV, raw_ch, channels) + return max(sample_rate, 1), max(channels, 1) diff --git a/vllm_omni/model_executor/models/common/ming/audio_vae.py b/vllm_omni/model_executor/models/common/ming/audio_vae.py index 89520f515..aecc35da9 100644 --- a/vllm_omni/model_executor/models/common/ming/audio_vae.py +++ b/vllm_omni/model_executor/models/common/ming/audio_vae.py @@ -138,8 +138,8 @@ class Encoder(nn.Module): 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) + 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) @@ -346,8 +346,9 @@ class AudioVAE(PreTrainedModel): h, _ = self.encoder(waveform) h = h.transpose(1, 2) - mean = torch.chunk(h, 2, dim=1)[0] - latent = mean.transpose(1, 2) + mean, scale = torch.chunk(h, 2, dim=1) + std = F.softplus(scale) + 1e-4 + latent = (mean + std * torch.randn_like(mean)).transpose(1, 2) return latent, frame_num @torch.inference_mode() diff --git a/vllm_omni/model_executor/models/ming_tts/patch_emission.py b/vllm_omni/model_executor/models/ming_tts/patch_emission.py index c0a25a1c1..89acdf231 100644 --- a/vllm_omni/model_executor/models/ming_tts/patch_emission.py +++ b/vllm_omni/model_executor/models/ming_tts/patch_emission.py @@ -127,7 +127,7 @@ def _validate_ming_decode_window( 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) + min_required = max(min_stop_step + 2, min_steps) if max_steps < min_required: req_id = info.get(KEY_REQUEST_ID, f"idx={i}") raise ValueError( @@ -148,7 +148,7 @@ def _resolve_ming_stop_decision( 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) + min_required_decode_steps = max(min_stop_step + 2, min_decode_steps) if max_decode_steps < min_required_decode_steps: raise RuntimeError( "Invalid Ming decode window: " From 20d43ce24d4c57bb57b395dbf2df8afbdaf34409 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Fri, 3 Jul 2026 21:35:26 +0800 Subject: [PATCH 016/101] [BugFix] Adapt to pin_memory in vLLM v0.24.0 (#4860) Signed-off-by: amy-why-3459 --- vllm_omni/worker/gpu_ar_model_runner.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index a04a83657..c1812b8c9 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -22,6 +22,7 @@ from vllm.distributed.kv_transfer import get_kv_transfer_group, has_kv_transfer_ from vllm.distributed.parallel_state import get_pp_group, get_tp_group from vllm.forward_context import set_forward_context from vllm.logger import init_logger +from vllm.utils.platform_utils import is_pin_memory_available from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput from vllm.v1.outputs import AsyncModelRunnerOutput, make_empty_encoder_model_runner_output from vllm.v1.spec_decode.dflash import DFlashProposer @@ -1459,7 +1460,16 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): multimodal_outputs=multimodal_outputs, ), copy_stream=self._get_or_create_omni_payload_copy_stream(), - pin_memory=bool(getattr(self, "pin_memory", False)), + # NOTE: vLLM v0.24.0's GPUModelRunner no longer exposes a + # ``self.pin_memory`` attribute (it uses a module-level + # ``PIN_MEMORY`` constant instead), so the old + # ``getattr(self, "pin_memory", False)`` silently fell back to + # False. That allocated the async D2H snapshot destination in + # *pageable* host memory, which turns ``copy_(non_blocking=True)`` + # into a fully synchronous, stream-stalling copy (~240 ms/step + # on the 17.5k-token Thinker prefill). Resolve pinning from the + # platform helper so the copy is a true async cudaMemcpyAsync. + pin_memory=is_pin_memory_available(), ) payload = async_payload_snapshot.payload From 46f10478dc8f1a4dcc150b424dc6210b0c861dd9 Mon Sep 17 00:00:00 2001 From: Kotaro Kikuchi Date: Sat, 4 Jul 2026 12:06:24 +0900 Subject: [PATCH 017/101] [Bugfix] Qwen-Image-Layered: allow layers=2 to match upstream demo (#4777) Signed-off-by: Kotaro Kikuchi --- .../online_serving/test_qwen_image_layered_expansion.py | 6 +++--- tests/entrypoints/openai_api/test_image_server.py | 8 ++++---- vllm_omni/entrypoints/openai/image_api_utils.py | 2 +- vllm_omni/entrypoints/openai/protocol/images.py | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/e2e/online_serving/test_qwen_image_layered_expansion.py b/tests/e2e/online_serving/test_qwen_image_layered_expansion.py index c26e21039..315fba6ef 100644 --- a/tests/e2e/online_serving/test_qwen_image_layered_expansion.py +++ b/tests/e2e/online_serving/test_qwen_image_layered_expansion.py @@ -7,7 +7,7 @@ and are supported by Qwen-Image-Layered model. Kept cases (maximum feature coverage, no cpu-offload): sp_001 : cache_dit + Ulysses-SP 2 (2×H100) cfg_parallel_002 : cache_dit + CFG-Parallel 2 (2×H100) - layers_guard_001 : layerwise offload + layers=3 (1×H100, issue #1969 guard) + layers_guard_001 : layerwise offload + layers=2 (1×H100, issue #1969 guard) Total distinct features covered: cache_dit, Ulysses-SP, CFG-Parallel, layerwise-offload. """ @@ -116,8 +116,8 @@ LAYERS_GUARD_CASES = [ model=MODEL, server_args=["--enable-layerwise-offload"], ), - 3, - id="layers_guard_001_layers3", + 2, + id="layers_guard_001_layers2", marks=SINGLE_CARD_FEATURE_MARKS, ), ] diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 31a82102a..5a7ef7bf0 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -1244,7 +1244,7 @@ def test_parameter_validation(): # Invalid layers for layered models (must stay within the backend-supported range) with pytest.raises(ValueError): - ImageGenerationRequest(prompt="test", layers=2) + ImageGenerationRequest(prompt="test", layers=1) with pytest.raises(ValueError): ImageGenerationRequest(prompt="test", layers=11) @@ -1663,13 +1663,13 @@ def test_image_edit_invalid_layers(async_omni_test_client): files=[("image", img_bytes)], data={ "prompt": "test", - "layers": 2, + "layers": 1, }, ) assert response.status_code == 400 detail = response.json()["detail"] assert "Invalid layers" in detail - assert "layers must be between 3 and 10 inclusive" in detail + assert "layers must be between 2 and 10 inclusive" in detail # Test layers above the supported range response = async_omni_test_client.post( @@ -1683,7 +1683,7 @@ def test_image_edit_invalid_layers(async_omni_test_client): assert response.status_code == 400 detail = response.json()["detail"] assert "Invalid layers" in detail - assert "layers must be between 3 and 10 inclusive" in detail + assert "layers must be between 2 and 10 inclusive" in detail def test_image_edit_resolution_and_size_conflict(async_omni_test_client): diff --git a/vllm_omni/entrypoints/openai/image_api_utils.py b/vllm_omni/entrypoints/openai/image_api_utils.py index b958d786a..5bd79e936 100644 --- a/vllm_omni/entrypoints/openai/image_api_utils.py +++ b/vllm_omni/entrypoints/openai/image_api_utils.py @@ -14,7 +14,7 @@ import io import PIL.Image SUPPORTED_LAYERED_RESOLUTIONS = (640, 1024) -SUPPORTED_LAYERED_LAYERS_RANGE = range(3, 11) +SUPPORTED_LAYERED_LAYERS_RANGE = range(2, 11) def parse_size(size_str: str) -> tuple[int, int]: diff --git a/vllm_omni/entrypoints/openai/protocol/images.py b/vllm_omni/entrypoints/openai/protocol/images.py index 78f63d387..b7247962c 100644 --- a/vllm_omni/entrypoints/openai/protocol/images.py +++ b/vllm_omni/entrypoints/openai/protocol/images.py @@ -60,7 +60,7 @@ class ImageGenerationRequest(BaseModel): user: str | None = Field(default=None, description="User identifier for tracking") layers: int | None = Field( default=None, - description="Number of output layers for layered image models. Supported range: 3-10.", + description="Number of output layers for layered image models. Supported range: 2-10.", ) @field_validator("size") From f7cdc45977958cd8901edccb11d8f481bca97f0c Mon Sep 17 00:00:00 2001 From: Haco <923390377@qq.com> Date: Sat, 4 Jul 2026 11:17:25 +0800 Subject: [PATCH 018/101] [BugFix] deploy schema stage runtime overrides for CLI diffusion flags no longer works (#4101) Signed-off-by: xiaohajiayou <923390377@qq.com> --- tests/test_config_factory.py | 191 +++++++++++++++++++++++++++++++ vllm_omni/config/stage_config.py | 87 +++++++++++--- 2 files changed, 262 insertions(+), 16 deletions(-) diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 63a67d652..23d36c338 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -1588,6 +1588,197 @@ class TestAuraOmniDeploy: assert stages[3].yaml_engine_args["model_arch"] == "Qwen3TTSCode2Wav" +class TestDeployCliOverrideFlow: + """Test deploy-YAML baselines overridden by CLI runtime overrides.""" + + def test_diffusion_deploy_fields_can_be_overridden_by_cli(self, tmp_path): + deploy_path = tmp_path / "diffusion_stage.yaml" + deploy_path.write_text( + """ +async_chunk: false +stages: + - stage_id: 0 + devices: "4,5" + parallel_config: + pipeline_parallel_size: 1 + data_parallel_size: 1 + tensor_parallel_size: 1 + enable_expert_parallel: false + sequence_parallel_size: 1 + ulysses_degree: 1 + ring_degree: 1 + cfg_parallel_size: 1 + vae_patch_parallel_size: 1 + use_hsdp: false + hsdp_shard_size: -1 + hsdp_replicate_size: 1 + engine_args: + cache_backend: cache_dit + diffusion_attention_backend: FLASH_ATTN + diffusion_kv_cache_dtype: auto + step_execution: false + vae_use_tiling: false + enable_cpu_offload: false + max_generated_image_size: 1048576 + tts_max_instructions_length: 1000 +""", + encoding="utf-8", + ) + + pipeline = PipelineConfig( + model_type="test_diffusion", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="dit", + execution_type=StageExecutionType.DIFFUSION, + final_output=True, + final_output_type="image", + ), + ), + ) + deploy = load_deploy_config(deploy_path) + stages = merge_pipeline_deploy(pipeline, deploy) + stage = stages[0] + + assert stage.yaml_engine_args["parallel_config"]["pipeline_parallel_size"] == 1 + assert stage.yaml_engine_args["parallel_config"]["data_parallel_size"] == 1 + assert stage.yaml_engine_args["parallel_config"]["tensor_parallel_size"] == 1 + assert stage.yaml_engine_args["parallel_config"]["enable_expert_parallel"] is False + assert stage.yaml_engine_args["parallel_config"]["sequence_parallel_size"] == 1 + assert stage.yaml_engine_args["parallel_config"]["ulysses_degree"] == 1 + assert stage.yaml_engine_args["parallel_config"]["ring_degree"] == 1 + assert stage.yaml_engine_args["parallel_config"]["cfg_parallel_size"] == 1 + assert stage.yaml_engine_args["parallel_config"]["vae_patch_parallel_size"] == 1 + assert stage.yaml_engine_args["cache_backend"] == "cache_dit" + assert stage.yaml_engine_args["parallel_config"]["use_hsdp"] is False + assert stage.yaml_engine_args["parallel_config"]["hsdp_shard_size"] == -1 + assert stage.yaml_engine_args["parallel_config"]["hsdp_replicate_size"] == 1 + assert stage.yaml_engine_args["diffusion_attention_backend"] == "FLASH_ATTN" + assert stage.yaml_engine_args["diffusion_kv_cache_dtype"] == "auto" + assert stage.yaml_engine_args["step_execution"] is False + assert stage.yaml_engine_args["vae_use_tiling"] is False + assert stage.yaml_engine_args["enable_cpu_offload"] is False + assert stage.yaml_engine_args["max_generated_image_size"] == 1048576 + assert stage.yaml_engine_args["tts_max_instructions_length"] == 1000 + + stage.runtime_overrides = StageConfigFactory._merge_cli_overrides( + stage, + { + "pipeline_parallel_size": 2, + "data_parallel_size": 3, + "tensor_parallel_size": 4, + "enable_expert_parallel": True, + "sequence_parallel_size": 24, + "ulysses_degree": 2, + "ring_degree": 4, + "cfg_parallel_size": 2, + "vae_patch_parallel_size": 2, + "cache_backend": "tea_cache", + "use_hsdp": True, + "hsdp_shard_size": 8, + "hsdp_replicate_size": 2, + "diffusion_attention_backend": "SAGE_ATTN", + "diffusion_kv_cache_dtype": "fp8", + "step_execution": True, + "vae_use_tiling": True, + "enable_cpu_offload": True, + "max_generated_image_size": 2097152, + "tts_max_instructions_length": 2000, + }, + ) + + omega_config = stage.to_omegaconf() + + assert omega_config.engine_args.cache_backend == "tea_cache" + assert omega_config.engine_args.diffusion_attention_backend == "SAGE_ATTN" + assert omega_config.engine_args.diffusion_kv_cache_dtype == "fp8" + assert omega_config.engine_args.step_execution is True + assert omega_config.engine_args.vae_use_tiling is True + assert omega_config.engine_args.enable_cpu_offload is True + assert omega_config.engine_args.max_generated_image_size == 2097152 + assert omega_config.engine_args.tts_max_instructions_length == 2000 + assert omega_config.engine_args.parallel_config.pipeline_parallel_size == 2 + assert omega_config.engine_args.parallel_config.data_parallel_size == 3 + assert omega_config.engine_args.parallel_config.tensor_parallel_size == 4 + assert omega_config.engine_args.parallel_config.enable_expert_parallel is True + assert omega_config.engine_args.parallel_config.sequence_parallel_size == 24 + assert omega_config.engine_args.parallel_config.ulysses_degree == 2 + assert omega_config.engine_args.parallel_config.ring_degree == 4 + assert omega_config.engine_args.parallel_config.cfg_parallel_size == 2 + assert omega_config.engine_args.parallel_config.vae_patch_parallel_size == 2 + assert omega_config.engine_args.parallel_config.use_hsdp is True + assert omega_config.engine_args.parallel_config.hsdp_shard_size == 8 + assert omega_config.engine_args.parallel_config.hsdp_replicate_size == 2 + + def test_llm_deploy_fields_can_be_overridden_by_cli(self, tmp_path): + deploy_path = tmp_path / "llm_stage.yaml" + deploy_path.write_text( + """ +async_chunk: false +stages: + - stage_id: 0 + devices: "0,1" + engine_args: + tensor_parallel_size: 1 + enable_expert_parallel: false + gpu_memory_utilization: 0.5 + max_num_seqs: 16 + max_num_batched_tokens: 1024 + max_model_len: 4096 + enforce_eager: false +""", + encoding="utf-8", + ) + + pipeline = PipelineConfig( + model_type="test_llm", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="thinker", + execution_type=StageExecutionType.LLM_AR, + final_output=True, + final_output_type="text", + ), + ), + ) + deploy = load_deploy_config(deploy_path) + stages = merge_pipeline_deploy(pipeline, deploy) + stage = stages[0] + + assert stage.yaml_engine_args["tensor_parallel_size"] == 1 + assert stage.yaml_engine_args["enable_expert_parallel"] is False + assert stage.yaml_engine_args["gpu_memory_utilization"] == 0.5 + assert stage.yaml_engine_args["max_num_seqs"] == 16 + assert stage.yaml_engine_args["max_num_batched_tokens"] == 1024 + assert stage.yaml_engine_args["max_model_len"] == 4096 + assert stage.yaml_engine_args["enforce_eager"] is False + + stage.runtime_overrides = StageConfigFactory._merge_cli_overrides( + stage, + { + "tensor_parallel_size": 2, + "enable_expert_parallel": True, + "gpu_memory_utilization": 0.9, + "max_num_seqs": 32, + "max_num_batched_tokens": 2048, + "max_model_len": 8192, + "enforce_eager": True, + }, + ) + + omega_config = stage.to_omegaconf() + + assert omega_config.engine_args.tensor_parallel_size == 2 + assert omega_config.engine_args.enable_expert_parallel is True + assert omega_config.engine_args.gpu_memory_utilization == 0.9 + assert omega_config.engine_args.max_num_seqs == 32 + assert omega_config.engine_args.max_num_batched_tokens == 2048 + assert omega_config.engine_args.max_model_len == 8192 + assert omega_config.engine_args.enforce_eager is True + + class TestSentinelDefaultPrecedence: """Caller-typed (non-None) values win over YAML; None values fall through to YAML / dataclass defaults (#3035).""" diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index a269216da..f66023bc4 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -63,7 +63,12 @@ def build_stage_runtime_overrides( if internal_keys is None: from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys - internal_keys = internal_blacklist_keys() | SHARED_FIELDS + # Some fields are modeled as orchestrator-owned for top-level CLI + # parsing, but are also legitimate deploy-time stage overrides. Keep + # the default blacklist for true orchestrator/shared fields while + # allowing any field explicitly represented by the deploy schema to + # continue flowing into per-stage overrides. + internal_keys = (internal_blacklist_keys() | SHARED_FIELDS) - deploy_runtime_override_keys() result: dict[str, Any] = {} @@ -303,7 +308,7 @@ class StageDeployConfig: the top level of ``DeployConfig`` and propagated to every stage. """ - # === Omni fields === + # === Omni stage wrapper fields === # Stage identity and Omni runtime placement. stage_id: int devices: str | None = None @@ -316,22 +321,33 @@ class StageDeployConfig: default_sampling_params: dict[str, Any] | None = None subtalker_sampling_params: dict[str, Any] | None = None - # === vLLM EngineArgs fields === - # Parallelism and scheduler/memory capacity. + # === Generic stage engine fields === + # Parallelism, scheduler, and memory-capacity controls. tensor_parallel_size: int | None = None + enable_expert_parallel: bool | None = None gpu_memory_utilization: float | None = None max_num_seqs: int | None = None max_num_batched_tokens: int | None = None max_model_len: int | None = None - # Execution, scheduling, and KV/cache behavior. + # Generic execution, scheduling, and KV/cache behavior. enforce_eager: bool | None = None async_scheduling: bool | None = None disable_hybrid_kv_cache_manager: bool | None = None mm_processor_cache_gb: float | None = None - # Diffusion parallel_config deploy override fields. - enable_expert_parallel: bool | None = None + # Generic compilation, profiling, tokenizer/config parsing, and model + # loading controls. + compilation_config: dict[str, Any] | None = None + profiler_config: dict[str, Any] | None = None + skip_mm_profiling: bool | None = None + enable_flashinfer_autotune: bool | None = None + config_format: str | None = None + load_format: str | None = None + tokenizer_mode: str | None = None + + # === Diffusion stage runtime fields === + # Diffusion parallel_config deploy/runtime override fields. ulysses_degree: int | None = None ulysses_mode: str | None = None ring_degree: int | None = None @@ -343,16 +359,44 @@ class StageDeployConfig: hsdp_shard_size: int | None = None hsdp_replicate_size: int | None = None - # Compilation, profiling, tokenizer/config parsing, and model loading. - compilation_config: dict[str, Any] | None = None - profiler_config: dict[str, Any] | None = None - skip_mm_profiling: bool | None = None - enable_flashinfer_autotune: bool | None = None - config_format: str | None = None - load_format: str | None = None - tokenizer_mode: str | None = None + # Diffusion model loading and adapter construction. + model_class_name: str | None = None + diffusion_load_format: str | None = None + diffusers_load_kwargs: dict[str, Any] | None = None + diffusers_call_kwargs: dict[str, Any] | None = None + diffusion_quantization_config: str | None = None + diffusion_attention_backend: str | None = None + diffusion_attention_config: dict[str, Any] | None = None - # Pass-through vLLM EngineArgs fields that are not represented above. + # Diffusion execution, cache, and VAE behavior. + cache_backend: str | None = None + cache_config: dict[str, Any] | None = None + enable_cache_dit_summary: bool | None = None + step_execution: bool | None = None + vae_use_slicing: bool | None = None + vae_use_tiling: bool | None = None + boundary_ratio: float | None = None + flow_shift: float | None = None + diffusion_kv_cache_dtype: str | None = None + diffusion_kv_cache_skip_steps: str | None = None + diffusion_kv_cache_skip_layers: str | None = None + auxiliary_text_encoder: str | None = None + + # Runtime optimizations used by diffusion loading/execution. + enable_multithread_weight_load: bool | None = None + num_weight_load_threads: int | None = None + enable_cpu_offload: bool | None = None + enable_layerwise_offload: bool | None = None + + # Diffusion-specific debug and observability knobs. + enable_diffusion_pipeline_profiler: bool | None = None + + # Modality/service constraints consumed outside the core engine config. + max_generated_image_size: int | None = None + tts_max_instructions_length: int | None = None + + # === Pass-through stage engine fields === + # Pass-through stage engine args that are not represented above. engine_extras: dict[str, Any] = field(default_factory=dict) @@ -409,6 +453,17 @@ _STAGE_RESERVED_KEYS = frozenset( _STAGE_DEPLOY_FIELDS = {f.name: f for f in fields(StageDeployConfig) if f.name not in _STAGE_RESERVED_KEYS} +def deploy_runtime_override_keys() -> frozenset[str]: + """Return deploy-schema fields that are valid CLI/runtime overrides. + + These keys form the positive contract for stage override propagation: + stage-scoped deploy knobs plus top-level pipeline-wide engine settings. + They must remain overridable even if they are also modeled on + ``OrchestratorArgs`` for top-level CLI parsing. + """ + return frozenset(_STAGE_DEPLOY_FIELDS) | frozenset(_PIPELINE_WIDE_ENGINE_FIELDS) + + def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig: """Parse a single stage entry from deploy YAML into StageDeployConfig.""" # Get the non-reserved keys for this stage From e73245c60fca55eba5e86b2704e8e266e88ad93b Mon Sep 17 00:00:00 2001 From: Yuanheng Zhao <54058983+yuanheng-zhao@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:45:49 +0800 Subject: [PATCH 019/101] [Bugfix] Adapt ZImagePipeline.forward calling in MingImagePipeline with DiffusionRequestBatch (#4866) Signed-off-by: Yuanheng Zhao --- .../ming_flash_omni/pipeline_ming_imagegen.py | 89 ++++++++----------- 1 file changed, 39 insertions(+), 50 deletions(-) diff --git a/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py b/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py index f3e0f3357..c67f8cb24 100644 --- a/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py +++ b/vllm_omni/diffusion/models/ming_flash_omni/pipeline_ming_imagegen.py @@ -24,7 +24,6 @@ from __future__ import annotations import logging import os -from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -49,7 +48,9 @@ from vllm_omni.diffusion.models.ming_flash_omni.ming_zimage_transformer import ( MingZImageTransformer2DModel, ) from vllm_omni.diffusion.models.z_image.pipeline_z_image import ZImagePipeline +from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.model_executor.model_loader.weight_utils import ( download_weights_from_hf_specific, ) @@ -58,38 +59,6 @@ from vllm_omni.transformers_utils.configs.ming_flash_omni import MingImageGenCon logger = logging.getLogger(__name__) -@dataclass -class _ZPipelineSamplingParams: - """Typed shim satisfying the attributes ZImagePipeline.forward reads - from ``req.sampling_params``. Unlike SimpleNamespace this fails loudly - at construction if a required field is missing.""" - - height: int - width: int - num_inference_steps: int - guidance_scale: float - generator: torch.Generator | None = None - strength: float | None = None - sigmas: list[float] | None = None - max_sequence_length: int = 512 - guidance_rescale: float | None = None - num_outputs_per_prompt: int = 1 - - -@dataclass -class _ZPipelineRequest: - """Typed shim satisfying the attributes ZImagePipeline.forward reads - from ``req``.""" - - request_id: str - sampling_params: _ZPipelineSamplingParams - prompts: list[dict[str, Any]] = field(default_factory=lambda: [{"prompt": "", "negative_prompt": ""}]) - - @property - def num_reqs(self) -> int: - return 1 - - class MingImagePipeline(ZImagePipeline): """Ming-flash-omni-2.0 text-to-image diffusion pipeline. @@ -123,6 +92,10 @@ class MingImagePipeline(ZImagePipeline): self.device = self._execution_device # Ming convention alias self._dtype = dtype + # Request-scoped conditioning handed to the inherited encode_prompt override + self._pending_prompt_embeds: list[torch.Tensor] | None = None + self._pending_negative_prompt_embeds: list[torch.Tensor] | None = None + # Ming's per-checkpoint image-gen configuration. We cannot rely on # ``od_config.hf_config.image_gen_config`` because the diffusion # stage is started with ``hf_config_name: thinker_config`` (the @@ -273,6 +246,21 @@ class MingImagePipeline(ZImagePipeline): latent = self.vae.encode(ref).latent_dist.mode() return (latent - self.vae.config.shift_factor) * self.vae.config.scaling_factor + def encode_prompt(self, *args, **kwargs): # noqa: ARG002 + """Return Ming's precomputed conditioning instead of encoding text. + + NOTE: Ming has no Z-Image text_encoder; its conditioning (cap_feats, optionally ByT5-augmented) + is computed in forward and stashed on `self._pending_*` immediately before + the `super().forward` call, so we simply hand it back here. + """ + if self._pending_prompt_embeds is None: + raise RuntimeError( + "MingImagePipeline.encode_prompt called without pending " + "conditioning; it must run within MingImagePipeline.forward." + ) + + return self._pending_prompt_embeds, self._pending_negative_prompt_embeds + # ------------------------------------------------------------------ # Forward # ------------------------------------------------------------------ @@ -416,17 +404,27 @@ class MingImagePipeline(ZImagePipeline): negative_prompt_embeds = [negative_cap_feats[i] for i in range(negative_cap_feats.shape[0])] else: negative_prompt_embeds = [self.condition_encoder.zero_negative(e) for e in prompt_embeds] + self._pending_prompt_embeds = prompt_embeds + self._pending_negative_prompt_embeds = negative_prompt_embeds - z_sp = _ZPipelineSamplingParams( + # Build a real single-request DiffusionRequestBatch; + # the prompt text itself is a neutral placeholder here. + z_sp = OmniDiffusionSamplingParams( height=height, width=width, num_inference_steps=num_inference_steps, guidance_scale=guidance_scale, generator=generator, + output_type="pt", ) - z_req = _ZPipelineRequest( - request_id=req.request_id or "ming-imagegen", - sampling_params=z_sp, + z_req = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest( + prompt={"prompt": ""}, + sampling_params=z_sp, + request_id=req.request_id or "ming-imagegen", + ) + ] ) # Reference image (img2img) → VAE-encoded latent published on the @@ -446,21 +444,12 @@ class MingImagePipeline(ZImagePipeline): None if ref_latent is None else tuple(ref_latent.shape), ) try: - outputs: DiffusionOutput = super().forward( - z_req, - prompt=None, - height=height, - width=width, - num_inference_steps=num_inference_steps, - guidance_scale=guidance_scale, - prompt_embeds=prompt_embeds, - negative_prompt_embeds=negative_prompt_embeds, - generator=generator, - output_type="pt", - return_dict=True, - ) + outputs: DiffusionOutput = super().forward(z_req) finally: set_forward_context_ref_latent(None) + # Drop request-scoped conditioning so we don't retain GPU tensors. + self._pending_prompt_embeds = None + self._pending_negative_prompt_embeds = None raw = outputs.output if not isinstance(raw, torch.Tensor): From 51c14eeaede4fb428f456dc785467ce995356518 Mon Sep 17 00:00:00 2001 From: CAICAII <3360776475@qq.com> Date: Sat, 4 Jul 2026 12:16:21 +0800 Subject: [PATCH 020/101] [BugFix] Preserve explicit deploy stage engine_extras (#4255) Signed-off-by: CAICAIIs <3360776475@qq.com> --- tests/helpers/stage_config.py | 13 +++++++ tests/test_config_factory.py | 59 +++++++++++++++++++++++++++++++- vllm_omni/config/stage_config.py | 3 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/tests/helpers/stage_config.py b/tests/helpers/stage_config.py index 4351eb27d..8706bb1bc 100644 --- a/tests/helpers/stage_config.py +++ b/tests/helpers/stage_config.py @@ -633,6 +633,18 @@ def get_deploy_config_path(rel_path: str) -> str: return str(_DEPLOY_DIR / rel_path) +def get_deploy_config_stage(rel_path: str, stage_id: int) -> dict[str, Any]: + """Return one stage entry from a deploy yaml by ``stage_id``.""" + with open(get_deploy_config_path(rel_path), encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + + stage_key = "stages" if "stages" in cfg else "stage_args" + for stage in cfg.get(stage_key, []): + if stage.get("stage_id") == stage_id: + return stage + raise KeyError(f"No stage_id={stage_id} in deploy config {rel_path!r}") + + def _get_config_value_by_path(config_dict: dict, path: str) -> Any: """Read a dot-separated path from a nested dict (e.g. ``engine_args.load_format``).""" current: Any = config_dict @@ -707,6 +719,7 @@ def stage_config_path_for_run_level(stage_config_path: str | None, run_level: st __all__ = [ "get_deploy_config_path", + "get_deploy_config_stage", "modify_stage_config", "stage_config_path_for_run_level", ] diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 23d36c338..4b956dfcd 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -14,7 +14,7 @@ from unittest.mock import patch import pytest from transformers import PretrainedConfig, Qwen3OmniMoeConfig -from tests.helpers.stage_config import get_deploy_config_path +from tests.helpers.stage_config import get_deploy_config_path, get_deploy_config_stage from vllm_omni.config.config_factory import StageConfigFactory from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline from vllm_omni.config.stage_config import ( @@ -739,6 +739,15 @@ stages: assert deploy.stages[0].compilation_config == {"pass_config": {"fuse_allreduce_rms": False}} assert "compilation_config" not in deploy.stages[0].engine_extras + def test_load_voxcpm2_deploy_config_preserves_engine_extras(self): + deploy_path = get_deploy_config_path("voxcpm2.yaml") + raw_stage = get_deploy_config_stage("voxcpm2.yaml", 0) + expected_runtime_config = raw_stage["engine_extras"]["hf_overrides"]["voxcpm2_runtime_config"] + + deploy = load_deploy_config(deploy_path) + runtime_config = deploy.stages[0].engine_extras["hf_overrides"]["voxcpm2_runtime_config"] + assert runtime_config == expected_runtime_config + def test_merge_pipeline_deploy(self): deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" if not deploy_path.exists(): @@ -928,6 +937,54 @@ stages: assert "foo" in stage.engine_extras assert stage.engine_extras["foo"] == {"a": 111, "b": {"d": 9, "e": 199}, "c": 3} + def test_explicit_engine_extras_merge_with_flat_and_engine_args(self): + """Explicit engine_extras should be preserved with existing pass-through styles.""" + fake_config = { + "stages": [ + { + "stage_id": 0, + "engine_extras": { + "hf_overrides": { + "runtime_config": { + "explicit_only": True, + "shared": "explicit", + "nested": {"a": 1}, + } + } + }, + "hf_overrides": { + "runtime_config": { + "flat_only": True, + "shared": "flat", + "nested": {"b": 2}, + } + }, + "engine_args": { + "hf_overrides": { + "runtime_config": { + "engine_args_only": True, + "shared": "engine_args", + "nested": {"c": 3}, + } + } + }, + }, + ] + } + + with patch("vllm_omni.config.stage_config.resolve_deploy_yaml", return_value=fake_config): + deploy = load_deploy_config("dummy.yaml") + + assert deploy.stages[0].engine_extras["hf_overrides"] == { + "runtime_config": { + "engine_args_only": True, + "explicit_only": True, + "flat_only": True, + "nested": {"a": 1, "b": 2, "c": 3}, + "shared": "engine_args", + } + } + def test_deep_merge_does_not_mutate_inputs(self): """Merging engine_args must not mutate the base stage dict.""" fake_config = { diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index f66023bc4..858e9fa15 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -468,6 +468,7 @@ def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig: """Parse a single stage entry from deploy YAML into StageDeployConfig.""" # Get the non-reserved keys for this stage flat_args = {k: v for k, v in stage_data.items() if k not in _STAGE_RESERVED_KEYS} + explicit_engine_extras = dict(stage_data.get("engine_extras") or {}) runtime_cfg = dict(stage_data.get("runtime", {})) devices = runtime_cfg.get("devices", stage_data.get("devices")) num_replicas = runtime_cfg.get("num_replicas", stage_data.get("num_replicas", 1)) @@ -495,7 +496,7 @@ def _parse_stage_deploy(stage_data: dict[str, Any]) -> StageDeployConfig: kwargs["output_connectors"] = stage_data.get("output_connectors") kwargs["input_connectors"] = stage_data.get("input_connectors") kwargs["default_sampling_params"] = stage_data.get("default_sampling_params") - kwargs["engine_extras"] = flat_args + kwargs["engine_extras"] = _get_recursively_merged_dict(explicit_engine_extras, flat_args) return StageDeployConfig(**kwargs) From 9eaca723829747bea832c2fe6980af4f32415248 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Sat, 4 Jul 2026 13:30:48 +0800 Subject: [PATCH 021/101] [Perf][VoxCPM2] Add unified decode graph optimization (#4606) Signed-off-by: Sy03 <1370724210@qq.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> --- ...ni_ar_scheduler_unified_decode_deferral.py | 114 +++ ...st_runner_assisted_unified_graph_static.py | 204 +++++ tests/test_config_factory.py | 21 + tests/worker/test_gpu_ar_model_runner.py | 138 +++ vllm_omni/benchmarks/patch/patch.py | 15 +- vllm_omni/config/stage_config.py | 3 +- vllm_omni/core/sched/omni_ar_scheduler.py | 14 + vllm_omni/deploy/voxcpm2.yaml | 35 +- .../model_executor/models/bagel/bagel.py | 8 +- .../model_executor/models/voxcpm2/pipeline.py | 1 + .../models/voxcpm2/scheduler.py | 42 + .../models/voxcpm2/voxcpm2_talker.py | 865 +++++++++++++++--- .../npu/worker/npu_ar_model_runner.py | 9 +- vllm_omni/worker/gpu_ar_model_runner.py | 209 ++++- vllm_omni/worker/runner_assisted_metadata.py | 33 + 15 files changed, 1542 insertions(+), 169 deletions(-) create mode 100644 tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py create mode 100644 tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py create mode 100644 vllm_omni/model_executor/models/voxcpm2/scheduler.py create mode 100644 vllm_omni/worker/runner_assisted_metadata.py diff --git a/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py b/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py new file mode 100644 index 000000000..8192a7b06 --- /dev/null +++ b/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from vllm.v1.request import RequestStatus + +import vllm_omni.core.sched.omni_ar_scheduler as scheduler_mod +from vllm_omni.model_executor.models.voxcpm2.scheduler import VoxCPM2OmniARAsyncScheduler + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _MockQueue: + def __init__(self, items: list | None = None) -> None: + self._items = list(items or []) + + def __bool__(self) -> bool: + return bool(self._items) + + def __iter__(self): + return iter(self._items) + + def __len__(self) -> int: + return len(self._items) + + def add_request(self, request) -> None: + self._items.append(request) + + def prepend_requests(self, requests) -> None: + self._items = list(requests) + self._items + + +class _MockRequest: + def __init__( + self, + request_id: str, + *, + status: RequestStatus = RequestStatus.RUNNING, + num_prompt_tokens: int = 4, + num_computed_tokens: int = 4, + num_output_placeholders: int = 0, + ) -> None: + self.request_id = request_id + self.status = status + self.num_prompt_tokens = num_prompt_tokens + self.num_computed_tokens = num_computed_tokens + self.num_output_placeholders = num_output_placeholders + + def is_finished(self) -> bool: + return RequestStatus.is_finished(self.status) + + +def _make_scheduler(*, enable_unified_decode_graph: bool = True) -> VoxCPM2OmniARAsyncScheduler: + sched = VoxCPM2OmniARAsyncScheduler.__new__(VoxCPM2OmniARAsyncScheduler) + runtime_config = SimpleNamespace(enable_unified_decode_graph=enable_unified_decode_graph) + sched.vllm_config = SimpleNamespace( + model_config=SimpleNamespace( + hf_config=SimpleNamespace(voxcpm2_runtime_config=runtime_config), + ) + ) + return sched + + +def test_voxcpm2_unified_decode_graph_defers_waiting_when_decode_ready() -> None: + scheduler = _make_scheduler() + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_voxcpm2_unified_decode_graph_does_not_defer_without_decode_ready() -> None: + scheduler = _make_scheduler() + scheduler.running = [_MockRequest("prefill-running", num_prompt_tokens=8, num_computed_tokens=4)] + scheduler.waiting = _MockQueue([_MockRequest("waiting", status=RequestStatus.WAITING)]) + + assert not scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_voxcpm2_unified_decode_graph_does_not_defer_when_disabled() -> None: + scheduler = _make_scheduler(enable_unified_decode_graph=False) + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert not scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None: + scheduler = _make_scheduler() + scheduler.running = [_MockRequest("decode")] + original_waiting_req = _MockRequest("waiting", status=RequestStatus.WAITING) + deferred_by_upstream = _MockRequest("deferred-by-upstream", status=RequestStatus.WAITING) + original_waiting = _MockQueue([original_waiting_req]) + scheduler.waiting = original_waiting + scheduler.policy = "fcfs" + scheduler.chunk_transfer_adapter = None + scheduler.input_coordinator = None + scheduler._consume_pending_connector_output = lambda model_mode: None + + monkeypatch.setattr(scheduler_mod, "create_request_queue", lambda _policy: _MockQueue()) + + def fake_upstream_schedule(self, throttle_prefills: bool = False): + assert self.waiting is not original_waiting + assert not self.waiting + self.waiting.add_request(deferred_by_upstream) + raise RuntimeError("stop before output wrapping") + + monkeypatch.setattr(scheduler_mod.VLLMScheduler, "schedule", fake_upstream_schedule) + + with pytest.raises(RuntimeError, match="stop before output wrapping"): + scheduler.schedule() + + assert scheduler.waiting._items == [deferred_by_upstream, original_waiting_req] diff --git a/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py b/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py new file mode 100644 index 000000000..33ac80a6f --- /dev/null +++ b/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Static regression checks for VoxCPM2 runner-assisted unified graph hooks. + +These tests intentionally avoid importing torch/vLLM so they can run in a +lightweight local checkout. Runtime audio quality still requires a CUDA test. +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[4] +RUNNER = REPO_ROOT / "vllm_omni" / "worker" / "gpu_ar_model_runner.py" +RUNNER_ASSISTED_METADATA = REPO_ROOT / "vllm_omni" / "worker" / "runner_assisted_metadata.py" +TALKER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "voxcpm2_talker.py" +VOXCPM2_PIPELINE = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "pipeline.py" +VOXCPM2_SCHEDULER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "scheduler.py" +OMNI_AR_SCHEDULER = REPO_ROOT / "vllm_omni" / "core" / "sched" / "omni_ar_scheduler.py" +DEPLOY = REPO_ROOT / "vllm_omni" / "deploy" / "voxcpm2.yaml" + + +def test_ar_runner_exposes_runner_assisted_full_metadata_hook(): + source = RUNNER.read_text() + + assert "Models without this hook keep the normal runner path" in source + assert "get_runner_assisted_full_attention_metadata_request" in source + assert "set_runner_assisted_full_attention_metadata_context" in source + assert "runner_assisted_full_attn" in source + assert "runner_assisted_full_attn_capture" in source + assert "pad_attn = cudagraph_mode == CUDAGraphMode.FULL or runner_assisted_full_attn" in source + assert "for_cudagraph_capture=runner_assisted_full_attn_capture" in source + assert "cudagraph_runtime_mode=(" in source + assert "CUDAGraphMode.FULL if runner_assisted_full_attn else cudagraph_mode" in source + refresh_source = source[source.index("def _refresh_runner_assisted_full_attention_metadata_buffers") :] + refresh_source = refresh_source[: refresh_source.index("def _set_runner_assisted_full_attention_metadata_context")] + assert "num_computed_tokens_cpu" in refresh_source + assert "num_scheduled_tokens_np" in refresh_source + assert "optimistic_seq_lens_cpu[:num_reqs]=1" not in "".join(refresh_source.split()) + padding_source = source[ + source.index("runner_assisted_full_attn_request = self._get_runner_assisted_full_attention_metadata_request") : + ] + padding_source = padding_source[: padding_source.index("ubatch_slices, ubatch_slices_padded")] + assert "runner_assisted_full_attn_request.num_reqs_padded" in padding_source + assert "runner_assisted_full_attn_request.for_cudagraph_capture" in padding_source + assert "num_reqs_padded * max_num_scheduled_tokens" in padding_source + + +def test_ar_runner_without_model_hook_stays_on_normal_path(): + source = RUNNER.read_text() + contract_source = RUNNER_ASSISTED_METADATA.read_text() + request_source = source[source.index("def _get_runner_assisted_full_attention_metadata_request") :] + request_source = request_source[ + : request_source.index("def _refresh_runner_assisted_full_attention_metadata_buffers") + ] + compact_request_source = "".join(request_source.split()) + + assert "Models without this hook keep the normal runner path" in request_source + assert "class RunnerAssistedAttentionMetadataProvider(Protocol)" in contract_source + assert "class RunnerAssistedFullAttentionMetadataRequest(NamedTuple)" in contract_source + assert "tuple[int, bool]" not in contract_source + assert 'hook=getattr(self.model,"get_runner_assisted_full_attention_metadata_request",None)' in ( + compact_request_source + ) + assert "ifnotcallable(hook):returnNone" in compact_request_source + assert ".tolist()" not in compact_request_source + assert "isinstance(request,RunnerAssistedFullAttentionMetadataRequest)" in compact_request_source + assert "request.num_reqs_padded" in request_source + assert "request.for_cudagraph_capture" in request_source + assert "exceptException" not in compact_request_source + + set_context_source = source[source.index("def _set_runner_assisted_full_attention_metadata_context") :] + set_context_source = set_context_source[: set_context_source.index("def _deferred_prefix_cache_mm_keys")] + assert "except Exception" not in set_context_source + + +def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise(): + source = TALKER.read_text() + compact_source = "".join(source.split()) + + assert "_voxcpm2_compile_without_inductor_cudagraphs" in source + assert "def _compile_without_inductor_cudagraphs" not in source + assert "_voxcpm2_unwrap_torch_compile" in source + assert "_orig_mod" in source + compile_source = source[source.index("def _voxcpm2_compile_without_inductor_cudagraphs") :] + compile_source = compile_source[: compile_source.index("def _setup_torch_compile")] + assert "torch._inductor.config" not in compile_source + assert '"triton.cudagraphs": False' in compile_source + assert '"triton.cudagraph_trees": False' in compile_source + + assert "self._enable_unified_decode_graph=(use_cuda_graph" in compact_source + assert "andnotself._deterministic_cfm_noise" in compact_source + assert "decode_tail_graph" not in source + + unified_source = source[source.index("def _forward_unified_decode") :] + unified_source = unified_source[: unified_source.index("# -------------------- vllm hooks")] + assert "except Exception:" not in unified_source + assert "_failed_unified_graph_sizes" not in source + assert "self._enable_unified_decode_graph = False" not in unified_source + assert "_forward_unified_decode_fallback" not in source + assert "capture_failed" not in compact_source + assert "record_fallback" not in source + assert "fallbacks" not in source + assert "**info_dict: Unpack[VoxCPM2PreprocessInput]" in source + assert "**info: Unpack[VoxCPM2PostprocessInput]" in source + assert "reference_audio: Any" not in source + assert "prompt_audio: Any" not in source + assert "def _nullify_volatile_metadata(ctx: _ForwardContextLike) -> _ForwardContextLike" in source + assert "class _PrefillInputs(NamedTuple)" in source + assert "-> _PrefillInputs" in source + assert 'inputs["audio_feat"]' not in source + assert 'inputs["text_token"]' not in source + + capture_source = source[source.index("def _capture_unified_decode_graph") :] + capture_source = capture_source[: capture_source.index("def _unified_decode_graph_skip_reason")] + assert "_voxcpm2_compile_unified_capture_estimator" in capture_source + assert "_voxcpm2_compile_unified_capture_feat_encoder" in capture_source + graph_body = capture_source[capture_source.index("with torch.cuda.graph") :] + assert "g.cfm_noise.normal_()" not in graph_body + assert "g.cfm_noise.normal_()" in unified_source + + assert "_pre_capture_unified_graphs" not in source + assert "_unified_graph_pre_capture_sizes" not in source + assert "unified_decode_graph_pre_capture_sizes" not in source + + +def test_voxcpm2_batch_unified_graph_requires_runner_metadata_marker(): + source = TALKER.read_text() + compact_source = "".join(source.split()) + + assert "enable_runner_assisted_unified_decode_graph" not in source + assert "allow_unified_decode_graph_batch_attention" not in source + assert "get_runner_assisted_full_attention_metadata_request" in source + assert "set_runner_assisted_full_attention_metadata_context" in source + assert "RunnerAssistedFullAttentionMetadataRequest" in source + assert "_runner_assisted_unified_decode_graph_active" in source + assert "runner_full_metadata_missing" in source + assert "_build_unified_graph_bucket_sizes" in source + assert "_select_unified_graph_bucket_size" in source + + capture_source = source[source.index("def _capture_unified_decode_graph") :] + capture_source = capture_source[: capture_source.index("def _unified_decode_graph_skip_reason")] + assert "override_forward_context" in capture_source + assert "_nullify_volatile_metadata" in capture_source + assert "capture_context = override_forward_context(self._nullify_volatile_metadata(ctx))" in capture_source + assert "ifsize>1:continue" not in compact_source + forward_source = source[source.index("def _forward_unified_decode") :] + forward_source = forward_source[: forward_source.index("# -------------------- vllm hooks")] + assert "graph_size = self._select_unified_graph_bucket_size(num_reqs)" in forward_source + assert "self._unified_graphs[graph_size]" in forward_source + assert "g.input_embeds[num_reqs:graph_size].zero_()" not in forward_source + assert "padded rows as valid duplicates" in forward_source + assert "g.input_embeds[last : last + 1].expand" in forward_source + assert "ifnum_reqs>1andnotself._runner_assisted_unified_decode_graph_active" in compact_source + assert "andnotcfg.enable_runner_assisted_unified_decode_graph" not in compact_source + needs_source = source[source.index("def get_runner_assisted_full_attention_metadata_request") :] + needs_source = needs_source[: needs_source.index("def set_runner_assisted_full_attention_metadata_context")] + assert "_select_unified_graph_bucket_size(num_reqs)" in needs_source + assert "_should_use_decode_graph(num_reqs)" not in needs_source + assert "RunnerAssistedFullAttentionMetadataRequest(" in needs_source + + +def test_voxcpm2_unified_skip_preserves_segmented_decode_graphs(): + source = TALKER.read_text() + compact_source = "".join(source.split()) + forward_source = source[source.index("def forward(") :] + forward_source = forward_source[: forward_source.index("# -------------------- prefill / decode helpers")] + + assert "use_segmented_decode_graph" in forward_source + assert "unified_skip_reasonisnotNone" in compact_source + assert "notself._enable_unified_decode_graphorunified_skip_reasonisnotNone" in compact_source + assert "ifuse_segmented_decode_graph:" in compact_source + assert "anduse_segmented_decode_graph" in compact_source + + +def test_voxcpm2_scheduler_policy_stays_model_local(): + common_source = OMNI_AR_SCHEDULER.read_text() + voxcpm2_scheduler_source = VOXCPM2_SCHEDULER.read_text() + pipeline_source = VOXCPM2_PIPELINE.read_text() + + assert "VoxCPM2TalkerForConditionalGeneration" not in common_source + assert "voxcpm2_runtime_config" not in common_source + assert "pure_decode_graph" not in common_source + assert "_schedule_with_optional_waiting_deferral" not in common_source + assert "def _should_defer_waiting_admission(self) -> bool:" in common_source + + assert "class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler)" in voxcpm2_scheduler_source + assert "voxcpm2_runtime_config" in voxcpm2_scheduler_source + assert "_should_defer_waiting_for_unified_decode_graph" in voxcpm2_scheduler_source + assert "def schedule(" not in voxcpm2_scheduler_source + assert "create_request_queue" not in voxcpm2_scheduler_source + assert "original_waiting.prepend_requests(deferred_waiting)" not in voxcpm2_scheduler_source + assert "return self._should_defer_waiting_for_unified_decode_graph()" in voxcpm2_scheduler_source + assert "vllm_omni.model_executor.models.voxcpm2.scheduler.VoxCPM2OmniARAsyncScheduler" in pipeline_source + + +def test_voxcpm2_deploy_defaults_to_full_unified_graph_only(): + source = DEPLOY.read_text() + + assert "max_num_seqs: 8" in source + assert "enable_unified_decode_graph: true" in source + assert "unified_decode_graph_max_batch_size: 8" in source + assert "unified_decode_graph_pre_capture_sizes" not in source + assert "enable_runner_assisted_unified_decode_graph" not in source + assert "allow_unified_decode_graph_batch_attention" not in source + assert "decode_graph_capture_policy" not in source diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 4b956dfcd..596a13dbb 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -571,6 +571,7 @@ class TestStagePipelineConfig: assert s.final_output is False assert s.sampling_constraints == {} assert s.engine_output_type is None + assert s.scheduler_cls is None class TestPipelineConfigNew: @@ -809,6 +810,26 @@ stages: assert stages[0].yaml_runtime["requires_multimodal_data"] is True + def test_merge_pipeline_deploy_preserves_pipeline_scheduler_cls(self): + scheduler_cls = "tests.fake.CustomScheduler" + pipeline = PipelineConfig( + model_type="test_scheduler", + model_arch="TestModel", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="ar", + execution_type=StageExecutionType.LLM_AR, + scheduler_cls=scheduler_cls, + ), + ), + ) + deploy = DeployConfig(async_chunk=False, stages=[StageDeployConfig(stage_id=0)]) + + stages = merge_pipeline_deploy(pipeline, deploy) + + assert stages[0].scheduler_cls == scheduler_cls + def test_mixed_schema_preserves_flat_fields(self): """Ensure flat fields are not dropped when engine_args are present.""" fake_config = { diff --git a/tests/worker/test_gpu_ar_model_runner.py b/tests/worker/test_gpu_ar_model_runner.py index a37c9b993..0e3a39abc 100644 --- a/tests/worker/test_gpu_ar_model_runner.py +++ b/tests/worker/test_gpu_ar_model_runner.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from types import SimpleNamespace import numpy as np @@ -10,6 +11,7 @@ from vllm_omni.worker.gpu_ar_model_runner import ( GPUARModelRunner, OmniAsyncGPUModelRunnerOutput, ) +from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -58,6 +60,101 @@ def test_sparse_mm_req_ids_requires_sparse_audio_marker(): assert GPUARModelRunner._sparse_mm_req_ids({"meta.req_id": ["r1"], "meta.sparse_audio": ["1"]}) == ["r1"] +def test_runner_assisted_full_attention_metadata_request_is_opt_in(): + runner = object.__new__(GPUARModelRunner) + runner.model = object() + runner.scheduler_config = SimpleNamespace(max_num_seqs=16) + + request = runner._get_runner_assisted_full_attention_metadata_request( + req_ids=["r1", "r2"], + num_reqs=2, + num_reqs_padded=4, + num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32), + num_computed_tokens_cpu=np.array([5, 6], dtype=np.int32), + max_num_scheduled_tokens=1, + ) + + assert request is None + + +def test_runner_assisted_full_attention_metadata_request_and_context_hooks(): + calls = [] + + class Model: + def get_runner_assisted_full_attention_metadata_request( + self, + *, + req_ids: Sequence[str], + num_reqs: int, + num_scheduled_tokens: Sequence[int], + num_computed_tokens: Sequence[int], + max_num_scheduled_tokens: int, + ) -> RunnerAssistedFullAttentionMetadataRequest: + calls.append( + ( + "request", + { + "req_ids": list(req_ids), + "num_reqs": num_reqs, + "num_scheduled_tokens": [int(n) for n in num_scheduled_tokens], + "num_computed_tokens": [int(n) for n in num_computed_tokens], + "max_num_scheduled_tokens": max_num_scheduled_tokens, + }, + ) + ) + return RunnerAssistedFullAttentionMetadataRequest( + num_reqs_padded=12, + for_cudagraph_capture=True, + ) + + def set_runner_assisted_full_attention_metadata_context( + self, + *, + enabled: bool, + num_reqs: int = 0, + ) -> None: + calls.append(("context", {"enabled": enabled, "num_reqs": num_reqs})) + + runner = object.__new__(GPUARModelRunner) + runner.model = Model() + runner.scheduler_config = SimpleNamespace(max_num_seqs=8) + + request = runner._get_runner_assisted_full_attention_metadata_request( + req_ids=["r1", "r2"], + num_reqs=2, + num_reqs_padded=4, + num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32), + num_computed_tokens_cpu=np.array([5, 6], dtype=np.int32), + max_num_scheduled_tokens=1, + ) + context_enabled = runner._set_runner_assisted_full_attention_metadata_context( + enabled=True, + num_reqs=2, + ) + context_disabled = runner._set_runner_assisted_full_attention_metadata_context(enabled=False) + + assert request == RunnerAssistedFullAttentionMetadataRequest( + num_reqs_padded=8, + for_cudagraph_capture=True, + ) + assert context_enabled + assert context_disabled + assert calls == [ + ( + "request", + { + "req_ids": ["r1", "r2"], + "num_reqs": 2, + "num_scheduled_tokens": [1, 1], + "num_computed_tokens": [5, 6], + "max_num_scheduled_tokens": 1, + }, + ), + ("context", {"enabled": True, "num_reqs": 2}), + ("context", {"enabled": False, "num_reqs": 0}), + ] + + def test_omni_async_gpu_model_runner_output_builds_lazily_once(): async_output = object.__new__(OmniAsyncGPUModelRunnerOutput) calls = [] @@ -357,6 +454,47 @@ def test_async_snapshot_payload_omits_hidden_when_model_opts_out(): assert payload["multimodal_outputs"]["codes"]["audio"].tolist() == [[1]] +def test_runner_assisted_full_attention_metadata_refresh_pads_buffers(): + class QueryStartLoc: + def __init__(self): + self.np = np.full(5, -1, dtype=np.int32) + self.copied = False + + def copy_to_gpu(self): + self.copied = True + + class BlockTable: + def __init__(self): + self.commits = [] + + def commit_block_table(self, num_reqs_padded): + self.commits.append(num_reqs_padded) + + runner = object.__new__(GPUARModelRunner) + block_table = BlockTable() + runner.input_batch = SimpleNamespace( + num_computed_tokens_cpu=torch.tensor([10, 20, 99, 99], dtype=torch.int32), + block_table=block_table, + ) + runner.optimistic_seq_lens_cpu = torch.zeros(4, dtype=torch.int32) + runner.seq_lens = torch.empty(4, dtype=torch.int32) + runner.query_pos = SimpleNamespace(np=np.empty(4, dtype=np.int32)) + runner.query_start_loc = QueryStartLoc() + runner._get_cumsum_and_arange = lambda scheduled, _query_pos: np.cumsum(scheduled, dtype=np.int32) + + runner._refresh_runner_assisted_full_attention_metadata_buffers( + num_reqs=2, + num_reqs_padded=4, + num_scheduled_tokens_np=np.array([1, 2], dtype=np.int32), + ) + + assert runner.optimistic_seq_lens_cpu.tolist() == [11, 22, 0, 0] + assert runner.seq_lens.tolist() == [11, 22, 0, 0] + assert runner.query_start_loc.np.tolist() == [0, 1, 3, 3, 3] + assert runner.query_start_loc.copied + assert block_table.commits == [4] + + @pytest.mark.parametrize("query_start_loc_attr", ["method", "tensor_attr"]) def test_sample_tokens_tail_only_prefix_cache_uses_staged_cpu_hidden_states(monkeypatch, query_start_loc_attr): runner = object.__new__(GPUARModelRunner) diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index a4eb683e6..e4f11b1e7 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -1264,7 +1264,7 @@ if "daily-omni" not in OPENAI_COMPATIBLE_BACKENDS: # Prevent import order from causing patch failures from vllm.benchmarks import serve from vllm.benchmarks.lib.ready_checker import wait_for_endpoint -from vllm.benchmarks.serve import TaskType, _merge_overrides, calculate_metrics_for_embeddings, get_request +from vllm.benchmarks.serve import TaskType, calculate_metrics_for_embeddings, get_request from vllm_omni.benchmarks.metrics.metrics import ( MultiModalsBenchmarkMetrics, @@ -1276,6 +1276,19 @@ from vllm_omni.benchmarks.metrics.metrics import ( benchmark_old = serve.benchmark +def _merge_overrides(base: dict | None, overrides: dict | None) -> dict | None: + """Merge benchmark extra_body with per-request overrides. + + vLLM 0.24 removed the private helper from ``vllm.benchmarks.serve``. + Keep the same shallow-merge behavior here, with request overrides winning. + """ + if not base and not overrides: + return None + merged = dict(base or {}) + merged.update(overrides or {}) + return merged + + async def benchmark( task_type: TaskType, endpoint_type: str, diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index 858e9fa15..5447fde7a 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -228,6 +228,7 @@ class StagePipelineConfig: prompt_expand_func: str | None = None cfg_kv_collect_func: str | None = None omni_kv_config: dict[str, Any] | None = None + scheduler_cls: str | None = None # Model subdirectory indirections: for multi-component HF repos where the # stage's config/tokenizer lives in a subdirectory (e.g. GLM-Image's AR # config is in ``vision_language_encoder/``). Consumed at stage-init time @@ -896,7 +897,7 @@ def merge_pipeline_deploy( final_output=ps.final_output, final_output_type=ps.final_output_type, worker_type=worker_type, - scheduler_cls=_scheduler_path(sched_cls), + scheduler_cls=ps.scheduler_cls or _scheduler_path(sched_cls), hf_config_name=ps.hf_config_name, is_comprehension=ps.owns_tokenizer, yaml_engine_args=engine_args, diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index 178fedee3..217c81543 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -11,6 +11,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStat from vllm.logger import init_logger from vllm.v1.core.sched.async_scheduler import AsyncScheduler as AsyncVLLMScheduler from vllm.v1.core.sched.output import SchedulerOutput +from vllm.v1.core.sched.request_queue import create_request_queue 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, FinishReason @@ -133,6 +134,9 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): self._omits_kv_transfer_cache[rid] = result return result + def _should_defer_waiting_admission(self) -> bool: + return False + def _process_kv_transfer_trigger(self, request: Request, new_token_ids: list[int]) -> bool: """ Check triggers and process side effects (marking transfer). @@ -222,9 +226,19 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): self.waiting, self.running, scheduler_requests=self.requests ) + original_waiting = None + if self._should_defer_waiting_admission(): + original_waiting = self.waiting + self.waiting = create_request_queue(self.policy) + try: scheduler_output = super().schedule(throttle_prefills) finally: + if original_waiting is not None: + deferred_waiting = list(self.waiting) + if deferred_waiting: + original_waiting.prepend_requests(deferred_waiting) + self.waiting = original_waiting if self.chunk_transfer_adapter: # Add request waiting for chunk to the waiting and running queue self.chunk_transfer_adapter.restore_queues( diff --git a/vllm_omni/deploy/voxcpm2.yaml b/vllm_omni/deploy/voxcpm2.yaml index 5d366521b..d534a41c1 100644 --- a/vllm_omni/deploy/voxcpm2.yaml +++ b/vllm_omni/deploy/voxcpm2.yaml @@ -8,16 +8,17 @@ dtype: bfloat16 stages: - stage_id: 0 - max_num_seqs: 32 + max_num_seqs: 8 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). + # max_num_seqs(8) * max_model_len(4096) ~= 32k tokens; 6 GiB leaves headroom + # for longer live requests while keeping VoxCPM2 TTS decode batches small + # enough to avoid the high ITL observed with large admission caps. # 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; + # Assumes prefix caching off and typical live decode concurrency <= ~8; # raise this cap if either grows or enable_prefix_caching is turned on. kv_cache_memory_bytes: 6442450944 # 6 GiB enforce_eager: true @@ -27,18 +28,20 @@ stages: max_num_batched_tokens: 4096 max_model_len: 4096 devices: "0" - engine_extras: - hf_overrides: - voxcpm2_runtime_config: - audio_emit_every: 1 - vae_decode_every: 3 - enable_batched_cfm: true - enable_batched_vae_decode: true - coalesce_audio_d2h: true - enable_loc_dit_fused_qkv: true - enable_loc_dit_fused_mlp: true - enable_loc_dit_zero_dt_cache: true - enable_loc_dit_skip_qkv_contig: true + hf_overrides: + voxcpm2_runtime_config: + audio_emit_every: 1 + vae_decode_every: 3 + enable_batched_cfm: true + enable_batched_vae_decode: true + coalesce_audio_d2h: true + enable_loc_dit_fused_qkv: true + enable_loc_dit_fused_mlp: true + enable_loc_dit_zero_dt_cache: true + enable_loc_dit_skip_qkv_contig: true + enable_loc_dit_reduce_overhead_no_cg: true + enable_unified_decode_graph: true + unified_decode_graph_max_batch_size: 8 default_sampling_params: temperature: 0.0 top_p: 1.0 diff --git a/vllm_omni/model_executor/models/bagel/bagel.py b/vllm_omni/model_executor/models/bagel/bagel.py index 282abed23..a3dfd7bf1 100644 --- a/vllm_omni/model_executor/models/bagel/bagel.py +++ b/vllm_omni/model_executor/models/bagel/bagel.py @@ -587,9 +587,9 @@ class OmniBagelForConditionalGeneration(BagelForConditionalGeneration): input_ids: torch.Tensor | None, positions: torch.Tensor | None, inputs_embeds: torch.Tensor | None, - req_ids: list[str], - num_computed_tokens: list[int], - num_scheduled_tokens: list[int], + req_ids: Sequence[str], + num_computed_tokens: Sequence[int], + num_scheduled_tokens: Sequence[int], input_ids_buffer: torch.Tensor | None = None, ) -> tuple[torch.Tensor | None, torch.Tensor | None]: """Restore input_ids so _adjust_positions_for_img2img can locate @@ -599,7 +599,7 @@ class OmniBagelForConditionalGeneration(BagelForConditionalGeneration): input_ids = input_ids_buffer return input_ids, positions - def flush_pending_metadata(self, req_ids: list[str]) -> None: + def flush_pending_metadata(self, req_ids: Sequence[str]) -> None: """Map pending metadata (batch order) to req_ids after forward(). Guard: if a request already has metadata with ``image_shape`` diff --git a/vllm_omni/model_executor/models/voxcpm2/pipeline.py b/vllm_omni/model_executor/models/voxcpm2/pipeline.py index 347fce177..0579ebc8c 100644 --- a/vllm_omni/model_executor/models/voxcpm2/pipeline.py +++ b/vllm_omni/model_executor/models/voxcpm2/pipeline.py @@ -26,6 +26,7 @@ VOXCPM2_PIPELINE = PipelineConfig( final_output_type="audio", owns_tokenizer=True, engine_output_type="audio", + scheduler_cls="vllm_omni.model_executor.models.voxcpm2.scheduler.VoxCPM2OmniARAsyncScheduler", sampling_constraints={ "detokenize": False, "stop_token_ids": [1], diff --git a/vllm_omni/model_executor/models/voxcpm2/scheduler.py b/vllm_omni/model_executor/models/voxcpm2/scheduler.py new file mode 100644 index 000000000..8aade3e74 --- /dev/null +++ b/vllm_omni/model_executor/models/voxcpm2/scheduler.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +from vllm.v1.request import RequestStatus + +from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler + + +class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler): + """VoxCPM2 scheduler variant for full unified decode graph serving. + + VoxCPM2's full unified decode graph only applies to pure decode batches. + When a decode-ready request is already running, this scheduler defers new + waiting admissions for the current tick so the decode batch can stay on the + VoxCPM2 graph path. This is a model-local serving policy, not a generic AR + scheduler rule. + """ + + def _unified_decode_graph_enabled(self) -> bool: + hf_config = getattr(self.vllm_config.model_config, "hf_config", None) + runtime_config = getattr(hf_config, "voxcpm2_runtime_config", None) + if isinstance(runtime_config, dict): + return bool(runtime_config.get("enable_unified_decode_graph", False)) + return bool(getattr(runtime_config, "enable_unified_decode_graph", False)) + + def _should_defer_waiting_for_unified_decode_graph(self) -> bool: + if not self._unified_decode_graph_enabled(): + return False + if not self.waiting or not self.running: + return False + + for request in self.running: + if getattr(request, "status", None) != RequestStatus.RUNNING or request.is_finished(): + continue + if self._get_confirmed_num_computed_tokens(request) >= request.num_prompt_tokens: + return True + return False + + def _should_defer_waiting_admission(self) -> bool: + return self._should_defer_waiting_for_unified_decode_graph() diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py index 9505bfbee..555fdf6a6 100644 --- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py +++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py @@ -16,12 +16,13 @@ import hashlib import logging import math import time -from collections.abc import Iterable +from collections.abc import Callable, Iterable, Sequence from types import MethodType -from typing import Any +from typing import Any, NamedTuple, Protocol, TypedDict import torch import torch.nn as nn +from typing_extensions import Unpack from vllm.config import VllmConfig from vllm.forward_context import get_forward_context, override_forward_context from vllm.inputs import tokens_input @@ -42,6 +43,7 @@ from vllm_omni.utils.speaker_cache import ( iter_custom_voice_profiles, load_validated_profile_tensors, ) +from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest from .minicpm4_paged import MiniCPM4PagedForVoxCPM2, MiniCPM4PagedResidualLM from .voxcpm2_import_utils import import_voxcpm2_core @@ -56,6 +58,52 @@ _ENABLE_NVTX_PROFILE = False _ACTIVE_STATE_LEAK_WARN_MIN = 512 +class _VoxCPM2PromptConfigLike(Protocol): + audio_vae_config: dict[str, Any] + patch_size: int + + +class _AttentionMetadataLike(Protocol): + scheduler_metadata: Any + + +class _ForwardContextLike(Protocol): + attn_metadata: dict[str, _AttentionMetadataLike] | Any + + +class VoxCPM2PreprocessInput(TypedDict, total=False): + additional_information: dict[str, Any] + request_id: str + text_token_ids: list[list[int]] + reference_audio: object + ref_audio: object + prompt_audio: object + prompt_text: str | list[str] | None + voice_profile: dict[str, Any] | list[dict[str, Any]] | None + voice_name: str | list[str] | None + voice_created_at: int | str | None + + +class VoxCPM2PostprocessInput(TypedDict, total=False): + request_id: str + + +class _PrefillInputs(NamedTuple): + text_token: torch.Tensor + audio_feat: torch.Tensor + text_mask: torch.Tensor + audio_mask: torch.Tensor + + +class _PrefillResidualMeta(NamedTuple): + lm_hidden: torch.Tensor + prefix_feat_cond: torch.Tensor + + +class _DecodeResidualMeta(NamedTuple): + new_lm_hidden: torch.Tensor + + @dataclasses.dataclass(frozen=True) class _VoxCPM2RuntimeConfig: enable_profiling: bool = False @@ -84,6 +132,9 @@ class _VoxCPM2RuntimeConfig: enable_batched_vae_decode: bool = True enable_batched_fsq_fusion: bool = False batched_fsq_fusion_max_batch: int = 32 + enable_batched_prefill_tail: bool = False + enable_unified_decode_graph: bool = True + unified_decode_graph_max_batch_size: int = 64 @classmethod def from_vllm_config(cls, vllm_config: VllmConfig) -> _VoxCPM2RuntimeConfig: @@ -133,6 +184,13 @@ class _VoxCPM2RuntimeConfig: "audio_emit_every > 1, and VAE CUDA Graph; disabling batched VAE decode." ) cfg = dataclasses.replace(cfg, enable_batched_vae_decode=False) + if cfg.enable_unified_decode_graph: + if cfg.enable_cfm_cuda_graph: + logger.info("VoxCPM2 unified decode graph supersedes independent CFM CUDA Graph; disabling it.") + cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False, enable_cfm_prealloc_output=False) + if not cfg.enable_batched_cfm: + logger.info("VoxCPM2 unified decode graph requires batched CFM; enabling it.") + cfg = dataclasses.replace(cfg, enable_batched_cfm=True) return cfg @staticmethod @@ -195,11 +253,11 @@ def split_multichar_chinese(token_ids: list[int], split_map: dict[int, list[int] def build_voxcpm2_prompt( - hf_config: Any, + hf_config: _VoxCPM2PromptConfigLike, tokenizer: Any, split_map: dict[int, list[int]], text: str, - ref_audio: Any | None = None, + ref_audio: Sequence[float] | torch.Tensor | None = None, ref_sr: int | None = None, ref_text: str | None = None, voice_profile: dict[str, Any] | None = None, @@ -234,6 +292,8 @@ def build_voxcpm2_prompt( prefill_len += len(prompt_ids) prefill_len += audio_feat_len elif ref_audio is not None: + if ref_sr is None: + raise ValueError("VoxCPM2 ref_sr is required when ref_audio is provided.") vae = hf_config.audio_vae_config patch_samples = hf_config.patch_size * math.prod(vae["encoder_rates"]) ref_len = math.ceil(math.ceil(len(ref_audio) * vae["sample_rate"] / ref_sr) / patch_samples) @@ -343,6 +403,46 @@ class _CapturedCFMGraph: buffers: _CFMBufferManager +@dataclasses.dataclass +class _CapturedUnifiedDecodeGraph: + graph: torch.cuda.CUDAGraph + batch_size: int + # Static input buffers (copied before replay) + input_embeds: torch.Tensor + positions: torch.Tensor + prev_feat_embed: torch.Tensor + prefix_feat_cond: torch.Tensor + # Static output buffers (read after replay) + next_feat_embed: torch.Tensor + cfm_output: torch.Tensor + lm_hidden: torch.Tensor + # Internal buffers for CFM solver + cfm_buffers: _CFMBufferManager + cfm_noise: torch.Tensor + + +@dataclasses.dataclass +class _UnifiedDecodeGraphStats: + captures: int = 0 + replays: int = 0 + skips: dict[str, int] = dataclasses.field(default_factory=dict) + real_batch_sizes: dict[int, int] = dataclasses.field(default_factory=dict) + graph_bucket_sizes: dict[int, int] = dataclasses.field(default_factory=dict) + logged_replays: int = 0 + logged_skips: int = 0 + + def record_skip(self, reason: str) -> None: + self.skips[reason] = self.skips.get(reason, 0) + 1 + + def record_replay_batch(self, *, real_batch_size: int, graph_bucket_size: int) -> None: + self.real_batch_sizes[real_batch_size] = self.real_batch_sizes.get(real_batch_size, 0) + 1 + self.graph_bucket_sizes[graph_bucket_size] = self.graph_bucket_sizes.get(graph_bucket_size, 0) + 1 + + @property + def total_skips(self) -> int: + return sum(self.skips.values()) + + @dataclasses.dataclass class _PendingAudioCopy: host: torch.Tensor @@ -927,6 +1027,17 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._max_cached_graphs = self._max_batch_size self._cuda_graph_warmup_steps = 0 self._cuda_graph_warmup_threshold = 3 + self._unified_graphs: dict[int, _CapturedUnifiedDecodeGraph] = {} + self._enable_unified_decode_graph = ( + use_cuda_graph and self._runtime_config.enable_unified_decode_graph and not self._deterministic_cfm_noise + ) + self._unified_decode_graph_max_batch_size = self._runtime_config.unified_decode_graph_max_batch_size + self._unified_graph_bucket_sizes: frozenset[int] = self._build_unified_graph_bucket_sizes( + min(self._max_batch_size, self._unified_decode_graph_max_batch_size) + ) + self._unified_graph_stats = _UnifiedDecodeGraphStats() + self._runner_assisted_unified_decode_graph_active = False + self._runner_assisted_unified_decode_graph_batch_size = 0 self._multichar_zh_split: dict[int, list[int]] | None = None @@ -1035,10 +1146,10 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): def _build_prompt_cache( self, - ref_audio: Any = None, - prompt_audio: Any = None, + ref_audio: object = None, + prompt_audio: object = None, prompt_text: str | None = None, - ) -> dict | None: + ) -> dict[str, Any] | None: """Build prompt cache, handling both file paths and raw audio data. The OpenAI speech API sends decoded audio as [samples_list, sr] @@ -1106,6 +1217,84 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): max_batch_size=self._max_batch_size, ) + @staticmethod + def _voxcpm2_unwrap_torch_compile(module: nn.Module | Callable) -> nn.Module | Callable: + """Return the eager module behind torch.compile wrappers.""" + seen: set[int] = set() + while hasattr(module, "_orig_mod") and id(module) not in seen: + seen.add(id(module)) + module = getattr(module, "_orig_mod") + return module + + @staticmethod + def _voxcpm2_compile_without_inductor_cudagraphs( + module: nn.Module | Callable, + *, + mode: str | None, + fullgraph: bool, + ) -> Callable: + """Compile VoxCPM2 LocDiT for capture inside VoxCPM2-owned CUDA graphs.""" + kwargs: dict[str, Any] = { + "fullgraph": fullgraph, + "options": { + "triton.cudagraphs": False, + "triton.cudagraph_trees": False, + }, + } + if mode is not None: + kwargs["mode"] = mode + return torch.compile(module, **kwargs) + + @staticmethod + def _raise_if_cuda_runtime_error(error: BaseException) -> None: + if isinstance(error, torch.cuda.OutOfMemoryError): + raise error + if isinstance(error, RuntimeError): + message = str(error).lower() + cuda_markers = ( + "cuda error", + "device-side assert", + "illegal memory access", + "out of memory", + "cublas", + "cudnn", + "cufft", + ) + if any(marker in message for marker in cuda_markers): + raise error + + def _voxcpm2_compile_unified_capture_estimator(self, module: nn.Module | Callable) -> Callable: + eager_module = self._voxcpm2_unwrap_torch_compile(module) + if getattr(self, "_estimator_for_unified_capture_source", None) is eager_module and hasattr( + self, "_estimator_for_unified_capture" + ): + return self._estimator_for_unified_capture + compiled = self._voxcpm2_compile_without_inductor_cudagraphs( + eager_module, + mode=None, + fullgraph=False, + ) + compiled._compiled = True + self._estimator_for_unified_capture_source = eager_module + self._estimator_for_unified_capture = compiled + return compiled + + def _voxcpm2_compile_unified_capture_feat_encoder(self, module: nn.Module | Callable) -> Callable: + eager_module = self._voxcpm2_unwrap_torch_compile(module) + if getattr(self, "_feat_encoder_for_unified_capture_source", None) is eager_module and hasattr( + self, "_feat_encoder_for_unified_capture" + ): + return self._feat_encoder_for_unified_capture + compiled = self._voxcpm2_compile_without_inductor_cudagraphs( + eager_module, + mode=None, + fullgraph=False, + ) + compiled._compiled = True + self._feat_encoder_for_unified_capture_source = eager_module + self._feat_encoder_for_unified_capture = compiled + return compiled + def _setup_torch_compile(self) -> None: if not self._enable_torch_compile: return @@ -1133,30 +1322,42 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): if _install_locdit_zero_dt_cache(estimator): targets.append("LocDiT zero-dt embedding cache") + external_cfm_capture = self._enable_cfm_cuda_graph or self._enable_unified_decode_graph if cfg.enable_loc_dit_layer_nvtx: patched = _install_locdit_layer_nvtx(estimator) if patched: targets.append(f"LocDiT layer NVTX ({patched} layers, compile skipped)") estimator._compiled = True - elif self._enable_cfm_cuda_graph: + elif self._enable_unified_decode_graph: + try: + compiled_ro = torch.compile(estimator, mode="reduce-overhead", fullgraph=False) + compiled_ro._compiled = True + tts.feat_decoder.estimator = compiled_ro + targets.append("LocDiT (reduce-overhead serving + lazy no-cg unified capture)") + except Exception as e: + self._raise_if_cuda_runtime_error(e) + logger.warning("torch.compile LocDiT dual-mode failed: %s", e) + try: + tts.feat_decoder.estimator = self._voxcpm2_compile_without_inductor_cudagraphs( + estimator, + mode="reduce-overhead", + fullgraph=False, + ) + tts.feat_decoder.estimator._compiled = True + self._estimator_for_unified_capture = tts.feat_decoder.estimator + targets.append("LocDiT (no-cg only, unified capture)") + except Exception as inner_e: + self._raise_if_cuda_runtime_error(inner_e) + logger.warning("torch.compile LocDiT failed completely: %s", inner_e) + elif external_cfm_capture: try: if cfg.enable_loc_dit_reduce_overhead_no_cg: - import torch._inductor.config as inductor_config - - old_cudagraphs = inductor_config.triton.cudagraphs - old_cudagraph_trees = inductor_config.triton.cudagraph_trees - inductor_config.triton.cudagraphs = False - inductor_config.triton.cudagraph_trees = False - try: - tts.feat_decoder.estimator = torch.compile( - estimator, - mode="reduce-overhead", - fullgraph=False, - ) - finally: - inductor_config.triton.cudagraphs = old_cudagraphs - inductor_config.triton.cudagraph_trees = old_cudagraph_trees - targets.append("LocDiT (reduce-overhead no-inductor-cudagraph + CUDA Graph)") + tts.feat_decoder.estimator = self._voxcpm2_compile_without_inductor_cudagraphs( + estimator, + mode="reduce-overhead", + fullgraph=False, + ) + targets.append("LocDiT (reduce-overhead no-inductor-cudagraph + external CUDA Graph)") else: tts.feat_decoder.estimator = torch.compile( estimator, @@ -1166,25 +1367,11 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): "triton.cudagraph_trees": False, }, ) - graph_mode = "fullgraph" if cfg.enable_loc_dit_fullgraph_no_cg else "partitioned" - targets.append(f"LocDiT ({graph_mode} compile no-inductor-cudagraph + CUDA Graph)") + targets.append("LocDiT (no-inductor-cudagraph + external CUDA Graph)") tts.feat_decoder.estimator._compiled = True except Exception as e: - logger.warning("torch.compile LocDiT reduce-overhead for CUDA Graph failed: %s", e) - try: - tts.feat_decoder.estimator = torch.compile( - estimator, - fullgraph=cfg.enable_loc_dit_fullgraph_no_cg, - options={ - "triton.cudagraphs": False, - "triton.cudagraph_trees": False, - }, - ) - tts.feat_decoder.estimator._compiled = True - graph_mode = "fullgraph" if cfg.enable_loc_dit_fullgraph_no_cg else "partitioned" - targets.append(f"LocDiT ({graph_mode} compile no-inductor-cudagraph + CUDA Graph)") - except Exception as inner_e: - logger.warning("torch.compile LocDiT for CUDA Graph failed: %s", inner_e) + self._raise_if_cuda_runtime_error(e) + logger.warning("torch.compile LocDiT for external CUDA Graph failed: %s", e) else: try: tts.feat_decoder.estimator = torch.compile( @@ -1195,14 +1382,34 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): tts.feat_decoder.estimator._compiled = True targets.append("LocDiT") except Exception as e: + self._raise_if_cuda_runtime_error(e) logger.warning("torch.compile LocDiT failed: %s", e) try: if not hasattr(tts.feat_encoder, "_compiled"): - tts.feat_encoder = torch.compile(tts.feat_encoder, mode="reduce-overhead", fullgraph=False) - tts.feat_encoder._compiled = True - targets.append("feat_encoder") + if self._enable_unified_decode_graph: + feat_encoder = tts.feat_encoder + compiled_ro = torch.compile(feat_encoder, mode="reduce-overhead", fullgraph=False) + compiled_ro._compiled = True + tts.feat_encoder = compiled_ro + targets.append("feat_encoder (reduce-overhead serving + lazy no-cg unified capture)") + elif external_cfm_capture: + tts.feat_encoder = torch.compile( + tts.feat_encoder, + fullgraph=False, + options={ + "triton.cudagraphs": False, + "triton.cudagraph_trees": False, + }, + ) + tts.feat_encoder._compiled = True + targets.append("feat_encoder (no-inductor-cudagraph)") + else: + tts.feat_encoder = torch.compile(tts.feat_encoder, mode="reduce-overhead", fullgraph=False) + tts.feat_encoder._compiled = True + targets.append("feat_encoder") except Exception as e: + self._raise_if_cuda_runtime_error(e) logger.warning("torch.compile feat_encoder failed: %s", e) if self._compile_vae and not self._enable_vae_cuda_graph: @@ -1212,6 +1419,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): tts.audio_vae._compiled = True targets.append("AudioVAE") except Exception as e: + self._raise_if_cuda_runtime_error(e) logger.warning("torch.compile AudioVAE failed: %s", e) if not self._enable_cuda_graph: @@ -1220,6 +1428,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): targets.extend(f"scaffold.{t}" for t in self.model.compile_selective()) self.model._selective_compiled = True except Exception as e: + self._raise_if_cuda_runtime_error(e) logger.warning("scaffold compile failed: %s", e) if not getattr(self.residual_model, "_selective_compiled", False): @@ -1227,6 +1436,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): targets.extend(f"residual.{t}" for t in self.residual_model.compile_selective()) self.residual_model._selective_compiled = True except Exception as e: + self._raise_if_cuda_runtime_error(e) logger.warning("residual compile failed: %s", e) else: self.model.precompute_fused_qkv() @@ -1240,6 +1450,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._projections_compiled = True targets.append("projections") except Exception as e: + self._raise_if_cuda_runtime_error(e) self._compiled_dit_proj = self._compiled_stop_fn = None logger.warning("projections compile failed: %s", e) @@ -1255,7 +1466,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): return tts.stop_head(tts.stop_actn(tts.stop_proj(lm_h))) @staticmethod - def _nullify_volatile_metadata(ctx: Any) -> Any: + def _nullify_volatile_metadata(ctx: _ForwardContextLike) -> _ForwardContextLike: """Set ``scheduler_metadata`` to None on all attention layers. This is the only tensor FA3 reallocates each step (variable shape). @@ -1351,6 +1562,31 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): ) return True + @staticmethod + def _build_unified_graph_bucket_sizes(max_size: int) -> frozenset[int]: + if max_size <= 0: + return frozenset() + sizes = {1} + size = 2 + while size <= min(max_size, 16): + sizes.add(size) + size <<= 1 + bucket = 24 + while bucket <= max_size: + sizes.add(bucket) + bucket += 8 + sizes.add(max_size) + return frozenset(size for size in sizes if 0 < size <= max_size) + + def _select_unified_graph_bucket_size(self, batch_size: int) -> int | None: + if ( + batch_size <= 0 + or batch_size > self._unified_decode_graph_max_batch_size + or batch_size > self._max_cached_graphs + ): + return None + return min((size for size in self._unified_graph_bucket_sizes if size >= batch_size), default=None) + def _capture_vae_graph(self, feat: torch.Tensor) -> _CapturedVAEGraph: batch_size = feat.shape[0] num_frames = feat.shape[-1] @@ -1535,8 +1771,297 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): state.cfm_output_gpu.copy_(graph.output) return state.cfm_output_gpu.transpose(1, 2) + # -------------------- unified decode graph -------------------- + + def _capture_unified_decode_graph(self, batch_size: int) -> _CapturedUnifiedDecodeGraph: + H = self.config.hidden_size + D = self._feat_dim + P = self._patch_size + dev = torch.device(self._device) + dtype = self._side_dtype + + self._setup_cfm_buffers() + if self._enable_torch_compile: + self._setup_torch_compile() + self.model.precompute_fused_qkv() + self.residual_model.precompute_fused_qkv() + + tts = self.tts + dit_hidden = tts.lm_to_dit_proj.out_features + tts.res_to_dit_proj.out_features + cfm_buffers = _CFMBufferManager( + device=dev, + dtype=dtype, + feat_dim=D, + patch_size=P, + dit_hidden_size=dit_hidden, + max_batch_size=batch_size, + ) + + g = _CapturedUnifiedDecodeGraph( + graph=torch.cuda.CUDAGraph(), + batch_size=batch_size, + input_embeds=torch.zeros(batch_size, H, device=dev, dtype=dtype), + positions=torch.zeros(batch_size, device=dev, dtype=torch.long), + prev_feat_embed=torch.zeros(batch_size, H, device=dev, dtype=dtype), + prefix_feat_cond=torch.zeros(batch_size, P, D, device=dev, dtype=dtype), + next_feat_embed=torch.zeros(batch_size, H, device=dev, dtype=dtype), + cfm_output=torch.zeros(batch_size, D, P, device=dev, dtype=dtype), + lm_hidden=torch.zeros(batch_size, H, device=dev, dtype=dtype), + cfm_buffers=cfm_buffers, + cfm_noise=torch.zeros(batch_size, D, P, device=dev, dtype=dtype), + ) + + dit_proj = self._dit_proj_fn + + def unified_fwd(): + scaffold_h = self.model(input_ids=None, positions=g.positions, inputs_embeds=g.input_embeds) + if isinstance(scaffold_h, tuple): + scaffold_h = scaffold_h[0] + lm_h = tts.fsq_layer(scaffold_h) + res_input = tts.fusion_concat_proj(torch.cat([lm_h, g.prev_feat_embed], dim=-1)) + res_h = self.residual_model(positions=g.positions, inputs_embeds=res_input) + dit_h = dit_proj(lm_h, res_h) + cond = g.prefix_feat_cond.transpose(1, 2).contiguous() + cfm_out = _optimized_solve_euler_with_noise( + tts.feat_decoder, + dit_h, + P, + cond, + g.cfm_noise, + self._inference_timesteps, + self._cfg_value, + cfm_buffers, + cfg_cutoff_ratio=self._cfg_cutoff_ratio, + ) + feat_enc = tts.feat_encoder(cfm_out.transpose(1, 2).unsqueeze(1)).squeeze(1) + next_embed = tts.enc_to_lm_proj(feat_enc) + return next_embed, cfm_out, lm_h + + ctx = get_forward_context() + capture_context = override_forward_context(self._nullify_volatile_metadata(ctx)) + + original_estimator = tts.feat_decoder.estimator + capture_estimator = self._voxcpm2_compile_unified_capture_estimator(original_estimator) + tts.feat_decoder.estimator = capture_estimator + + original_feat_encoder = tts.feat_encoder + capture_feat_encoder = self._voxcpm2_compile_unified_capture_feat_encoder(original_feat_encoder) + tts.feat_encoder = capture_feat_encoder + + try: + with capture_context: + with torch.no_grad(): + for _ in range(3): + g.cfm_noise.normal_() + unified_fwd() + + g.cfm_noise.normal_() + with torch.cuda.graph(g.graph, pool=current_platform.get_global_graph_pool()): + g.next_feat_embed, g.cfm_output, g.lm_hidden = unified_fwd() + finally: + tts.feat_decoder.estimator = original_estimator + tts.feat_encoder = original_feat_encoder + self._unified_graph_stats.captures += 1 + logger.info( + "CUDA Graph captured for unified decode (batch_size=%d, captures=%d)", + batch_size, + self._unified_graph_stats.captures, + ) + return g + + def _unified_decode_graph_skip_reason( + self, + *, + can_use_graph: bool, + is_all_decode: bool, + num_reqs: int, + ) -> str | None: + if not self._enable_unified_decode_graph: + return "disabled" + if not can_use_graph: + return "graph_not_ready" + if not is_all_decode: + return "not_all_decode" + if num_reqs > self._unified_decode_graph_max_batch_size: + return "batch_exceeds_unified_max" + if num_reqs > self._max_cached_graphs: + return "batch_too_large" + graph_size = self._select_unified_graph_bucket_size(num_reqs) + if graph_size is None: + return "capture_policy" + if num_reqs > 1 and not self._runner_assisted_unified_decode_graph_active: + return "runner_full_metadata_missing" + for req_id, _, _, _ in self._pending_requests: + state = self._active_states[req_id] + if not state.prefill_completed: + return "prefill_incomplete" + if state.prev_feat_embed is None or state.curr_prefix_feat_cond is None: + return "state_not_ready" + return None + + def _maybe_log_unified_graph_stats(self) -> None: + if not self._enable_profiling: + return + stats = self._unified_graph_stats + total_skips = stats.total_skips + should_log = (stats.replays - stats.logged_replays >= 100) or (total_skips - stats.logged_skips >= 50) + if not should_log: + return + stats.logged_replays = stats.replays + stats.logged_skips = total_skips + logger.info( + "VoxCPM2 unified graph stats: captures=%d replays=%d skips=%s real_batch_sizes=%s graph_bucket_sizes=%s", + stats.captures, + stats.replays, + stats.skips, + dict(sorted(stats.real_batch_sizes.items())), + dict(sorted(stats.graph_bucket_sizes.items())), + ) + + def _forward_unified_decode( + self, + inputs_embeds: torch.Tensor, + positions: torch.Tensor, + num_reqs: int, + ) -> torch.Tensor: + graph_size = self._select_unified_graph_bucket_size(num_reqs) + if graph_size is None: + raise RuntimeError(f"No unified decode graph bucket for batch size {num_reqs}") + g = self._unified_graphs.get(graph_size) + if g is None: + g = self._capture_unified_decode_graph(graph_size) + self._unified_graphs[graph_size] = g + + self._unified_graph_stats.replays += 1 + if self._enable_profiling: + self._unified_graph_stats.record_replay_batch(real_batch_size=num_reqs, graph_bucket_size=graph_size) + self._maybe_log_unified_graph_stats() + states: list[_RequestState] = [] + commit_mask: list[bool] = [] + for req_id, _is_prefill, _embeds, _n in self._pending_requests: + state = self._active_states[req_id] + states.append(state) + already_stopping = state.is_stopping + commit_mask.append(not already_stopping) + if not already_stopping: + state.decode_step_count += 1 + if state.decode_step_count >= self._max_decode_steps: + state.is_stopping = True + + self._perf.start("unified.copy_inputs") + for i, state in enumerate(states): + pfe = state.prev_feat_embed + g.prev_feat_embed[i].copy_(pfe.squeeze(0) if pfe.ndim > 1 else pfe) + pfc = state.curr_prefix_feat_cond + if pfc.ndim == 2: + g.prefix_feat_cond[i].copy_(pfc) + else: + g.prefix_feat_cond[i].copy_(pfc.squeeze(0)) + + g.input_embeds[:num_reqs].copy_(inputs_embeds[:num_reqs]) + g.positions[:num_reqs].copy_(positions[:num_reqs]) + if graph_size > num_reqs: + # Runner-assisted FULL metadata pads attention to graph_size. Keep + # padded rows as valid duplicates instead of position-0 zero rows; + # some attention backends may still touch padded slots before + # honoring seq_lens=0. + last = num_reqs - 1 + g.input_embeds[num_reqs:graph_size].copy_(g.input_embeds[last : last + 1].expand(graph_size - num_reqs, -1)) + g.positions[num_reqs:graph_size].copy_(g.positions[last : last + 1].expand(graph_size - num_reqs)) + g.prev_feat_embed[num_reqs:graph_size].copy_( + g.prev_feat_embed[last : last + 1].expand(graph_size - num_reqs, -1) + ) + g.prefix_feat_cond[num_reqs:graph_size].copy_( + g.prefix_feat_cond[last : last + 1].expand(graph_size - num_reqs, -1, -1) + ) + g.cfm_noise.normal_() + self._perf.stop("unified.copy_inputs") + + self._perf.start("unified.replay") + g.graph.replay() + self._perf.stop("unified.replay") + + self._perf.start("unified.commit") + with torch.no_grad(): + all_stop_logits = self._stop_fn(g.lm_hidden[:num_reqs]) + for i, state in enumerate(states): + stop_logits_i = all_stop_logits[i : i + 1] + if not commit_mask[i]: + state.precomputed_stop_logits = stop_logits_i + continue + next_embed_i = g.next_feat_embed[i : i + 1].clone() + cfm_out_i = g.cfm_output[i : i + 1].transpose(1, 2) + self._commit_decode_state(state, stop_logits_i, next_embed_i, cfm_out_i) + self._perf.stop("unified.commit") + + self._perf.start("unified.audio") + self._precompute_stop_flags_for_audio_collect(states) + ready_audio = self._drain_ready_audio_copies_for_states(states) + audio_by_req = self._collect_audio_batch(states, initial_delayed_chunks_by_req=ready_audio) + for state in states: + self._results_queue.append((state.request_id, state.precomputed_stop_logits)) + self._audio_queue.append((state.request_id, audio_by_req.get(state.request_id))) + + self._pending_requests.clear() + self._flush_deferred_cleanup() + self._perf.stop("unified.audio") + return g.next_feat_embed[:num_reqs] + # -------------------- vllm hooks -------------------- + def get_runner_assisted_full_attention_metadata_request( + self, + *, + req_ids: Sequence[str], + num_reqs: int, + num_scheduled_tokens: Sequence[int], + num_computed_tokens: Sequence[int], + max_num_scheduled_tokens: int, + ) -> RunnerAssistedFullAttentionMetadataRequest | None: + if not self._enable_unified_decode_graph or not self._enable_cuda_graph: + return None + if num_reqs <= 1 or num_reqs > self._unified_decode_graph_max_batch_size: + return None + if num_reqs > self._max_cached_graphs or self._select_unified_graph_bucket_size(num_reqs) is None: + return None + if max_num_scheduled_tokens != 1 or len(num_scheduled_tokens) != num_reqs: + return None + if any(int(n) != 1 for n in num_scheduled_tokens): + return None + if len(req_ids) != num_reqs or len(num_computed_tokens) != num_reqs: + return None + if any(int(n) <= 0 for n in num_computed_tokens): + return None + tts_compiled = getattr(self.tts.feat_decoder.estimator, "_compiled", False) if self._tts is not None else False + if not tts_compiled or self._cuda_graph_warmup_steps < self._cuda_graph_warmup_threshold: + return None + for req_id in req_ids: + state = self._active_states.get(req_id) + if ( + state is None + or not state.prefill_completed + or state.prev_feat_embed is None + or state.curr_prefix_feat_cond is None + ): + return None + bucket_size = self._select_unified_graph_bucket_size(num_reqs) + if bucket_size is None: + return None + return RunnerAssistedFullAttentionMetadataRequest( + num_reqs_padded=bucket_size, + for_cudagraph_capture=bucket_size not in self._unified_graphs, + ) + + def set_runner_assisted_full_attention_metadata_context( + self, + *, + enabled: bool, + num_reqs: int = 0, + ) -> None: + enabled = bool(enabled) + self._runner_assisted_unified_decode_graph_active = enabled + self._runner_assisted_unified_decode_graph_batch_size = int(num_reqs) if enabled else 0 + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: return self.model.embed_input_ids(input_ids) @@ -1565,12 +2090,30 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._enable_cuda_graph and graph_ready and intermediate_tensors is None and inputs_embeds is not None ) - if can_use_graph and is_all_decode and num_reqs <= self._max_cached_graphs: - use_scaffold_graph = self._should_use_decode_graph(num_reqs) - else: - use_scaffold_graph = False + unified_skip_reason = self._unified_decode_graph_skip_reason( + can_use_graph=can_use_graph, + is_all_decode=is_all_decode, + num_reqs=num_reqs, + ) + if unified_skip_reason is None: + self._perf.start("unified_decode") + result = self._forward_unified_decode(inputs_embeds, positions, num_reqs) + self._perf.stop("unified_decode") + self._perf.stop("forward_total") + return result + if self._enable_unified_decode_graph and num_reqs > 0 and unified_skip_reason is not None: + self._unified_graph_stats.record_skip(unified_skip_reason) + self._maybe_log_unified_graph_stats() - if use_scaffold_graph: + use_segmented_decode_graph = ( + can_use_graph + and is_all_decode + and num_reqs <= self._max_cached_graphs + and (not self._enable_unified_decode_graph or unified_skip_reason is not None) + and self._should_use_decode_graph(num_reqs) + ) + + if use_segmented_decode_graph: self._perf.start("scaffold_fwd") if num_reqs not in self._scaffold_graphs: self._scaffold_graphs[num_reqs] = self._capture_graph(self.model, num_reqs, "scaffold") @@ -1590,7 +2133,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): token_offset = 0 residual_inputs: list[torch.Tensor] = [] residual_positions: list[torch.Tensor] = [] - req_metas: list[tuple[_RequestState, bool, dict]] = [] + req_metas: list[tuple[_RequestState, bool, _PrefillResidualMeta | _DecodeResidualMeta]] = [] pending_decode_fsq: list[tuple[_RequestState, torch.Tensor, torch.Tensor]] = [] def flush_decode_fsq_batch() -> None: @@ -1656,6 +2199,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): residual_batch_size = batch_in.shape[0] use_residual_graph = ( self._enable_cuda_graph + and use_segmented_decode_graph and is_all_decode and graph_ready and residual_batch_size == num_reqs # 1 token per request @@ -1679,12 +2223,12 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): batch_out = self.residual_model(batch_pos, batch_in) self._perf.stop("residual_fwd") - can_batch_decode_tail = ( + can_finish_decode_batch = ( bool(req_metas) and all(not is_prefill for _, is_prefill, _ in req_metas) and all(x.shape[0] == 1 for x in residual_inputs) ) - if can_batch_decode_tail: + if can_finish_decode_batch: self._finish_decode_batch(req_metas, batch_out) self._precompute_stop_flags_for_audio_collect([state for state, _, _ in req_metas]) ready_audio_by_req = self._drain_ready_audio_copies_for_states([state for state, _, _ in req_metas]) @@ -1698,17 +2242,24 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): else: offset = 0 decoded_states: list[_RequestState] = [] + prefill_batch: list[tuple[_RequestState, _PrefillResidualMeta, torch.Tensor]] = [] for idx, (state, is_prefill, meta) in enumerate(req_metas): n = residual_inputs[idx].shape[0] res_out = batch_out[offset : offset + n] offset += n if is_prefill: - self._finish_prefill(state, meta, res_out, dev) + prefill_batch.append((state, meta, res_out)) else: - self._finish_decode(state, meta, res_out, dev) + self._finish_decode(state, meta, res_out) decoded_states.append(state) + if prefill_batch and self._runtime_config.enable_batched_prefill_tail: + self._finish_prefill_batch(prefill_batch) + else: + for state, meta, res_out in prefill_batch: + self._finish_prefill(state, meta, res_out, dev) + collect_states = [state for state, _, _ in req_metas] self._precompute_stop_flags_for_audio_collect(collect_states) ready_audio_by_req = self._drain_ready_audio_copies_for_states(collect_states) @@ -1731,7 +2282,12 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): # -------------------- prefill / decode helpers -------------------- - def _prepare_residual_prefill(self, state: _RequestState, base_lm_out: torch.Tensor, dev: Any): + def _prepare_residual_prefill( + self, + state: _RequestState, + base_lm_out: torch.Tensor, + dev: torch.device, + ) -> tuple[torch.Tensor, _PrefillResidualMeta]: tts = self.tts text_mask, feat_mask, feat, feat_embed = state.prefill_masks state.prefill_masks = None @@ -1754,10 +2310,15 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): lm_hidden = enc_outputs[:, -1, :] residual_input = tts.fusion_concat_proj(torch.cat([enc_outputs, feat_mask.unsqueeze(-1) * feat_embed], dim=-1)) - meta = {"lm_hidden": lm_hidden, "prefix_feat_cond": prefix_feat_cond} + meta = _PrefillResidualMeta(lm_hidden=lm_hidden, prefix_feat_cond=prefix_feat_cond) return residual_input.squeeze(0), meta - def _prepare_residual_decode(self, state: _RequestState, base_lm_out: torch.Tensor, dev: Any): + def _prepare_residual_decode( + self, + state: _RequestState, + base_lm_out: torch.Tensor, + dev: torch.device, + ) -> tuple[torch.Tensor, _DecodeResidualMeta]: tts = self.tts state.decode_step_count += 1 @@ -1774,14 +2335,14 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): if prev.ndim == 1: prev = prev.unsqueeze(0) res_input = tts.fusion_concat_proj(torch.cat([lm_h, prev], dim=-1)) - return res_input, {"new_lm_hidden": lm_h} + return res_input, _DecodeResidualMeta(new_lm_hidden=lm_h) def _prepare_residual_decode_batch( self, states: list[_RequestState], base_lm_outs: list[torch.Tensor], - dev: Any, - ) -> list[tuple[torch.Tensor, dict]]: + dev: torch.device, + ) -> list[tuple[torch.Tensor, _DecodeResidualMeta]]: tts = self.tts for state in states: @@ -1811,7 +2372,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): return [ ( res_batch[i : i + 1], - {"new_lm_hidden": lm_h_batch[i : i + 1]}, + _DecodeResidualMeta(new_lm_hidden=lm_h_batch[i : i + 1]), ) for i in range(len(states)) ] @@ -1906,14 +2467,96 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): out.normal_(generator=gen) state.cfm_noise_step += 1 - def _finish_prefill(self, state: _RequestState, meta: dict, res_out: torch.Tensor, dev: Any): + def _finish_prefill_batch(self, batch: list[tuple[_RequestState, _PrefillResidualMeta, torch.Tensor]]) -> None: + if len(batch) == 1: + state, meta, res_out = batch[0] + self._finish_prefill(state, meta, res_out, None) + return + + self._perf.start("prefill_tail_batch") tts = self.tts - lm_hidden = meta["lm_hidden"] - prefix_feat_cond = meta["prefix_feat_cond"] + self._setup_cfm_buffers() + if self._enable_torch_compile: + self._setup_torch_compile() + + dit_hs: list[torch.Tensor] = [] + conds: list[torch.Tensor] = [] + stop_logits_list: list[torch.Tensor] = [] + + for state, meta, res_out in batch: + lm_hidden = meta.lm_hidden + prefix_feat_cond = meta.prefix_feat_cond + residual_hidden = res_out[-1:, :] + stop_logits = tts.stop_head(tts.stop_actn(tts.stop_proj(lm_hidden))).detach() + dit_h = torch.cat([tts.lm_to_dit_proj(lm_hidden), tts.res_to_dit_proj(residual_hidden)], dim=-1) + cond = prefix_feat_cond.transpose(1, 2).contiguous() + dit_hs.append(dit_h) + conds.append(cond) + stop_logits_list.append(stop_logits) + + batched_dit_h = torch.cat(dit_hs, dim=0) + batched_cond = torch.cat(conds, dim=0) + b = batched_dit_h.shape[0] + + if self._deterministic_cfm_noise and self._cfm_buffers is not None: + noise = self._cfm_buffers.noise[:b] + for i, (state, _, _) in enumerate(batch): + self._fill_deterministic_cfm_noise(state, noise[i : i + 1]) + else: + noise = torch.randn( + b, + self._feat_dim, + self._patch_size, + device=batched_dit_h.device, + dtype=batched_dit_h.dtype, + ) + pred_feats = _optimized_solve_euler_with_noise( + tts.feat_decoder, + batched_dit_h, + self._patch_size, + batched_cond, + noise, + self._inference_timesteps, + self._cfg_value, + self._cfm_buffers, + cfg_cutoff_ratio=self._cfg_cutoff_ratio, + ).transpose(1, 2) + + with torch.no_grad(): + curr_embeds = tts.enc_to_lm_proj(tts.feat_encoder(pred_feats.unsqueeze(1))).squeeze(1) + + for i, (state, meta, res_out) in enumerate(batch): + pred_feat_i = pred_feats[i : i + 1] + curr_embed_i = curr_embeds[i : i + 1] + self._commit_decode_state(state, stop_logits_list[i], curr_embed_i, pred_feat_i) + state.decode_step_count = 0 + state.request_start_time = time.perf_counter() + state.prefill_completed = True + + self._perf.stop("prefill_tail_batch") + if self._enable_profiling: + logger.info("PREFILL_BATCH[%d requests] tail breakdown:\n%s", len(batch), self._perf.breakdown()) + self._perf.reset() + + def _finish_prefill( + self, + state: _RequestState, + meta: _PrefillResidualMeta, + res_out: torch.Tensor, + dev: torch.device | None, + ) -> None: + self._perf.start("prefill_tail") + tts = self.tts + lm_hidden = meta.lm_hidden + prefix_feat_cond = meta.prefix_feat_cond residual_hidden = res_out[-1:, :] + self._perf.start("prefill.stop_fn") stop_logits = tts.stop_head(tts.stop_actn(tts.stop_proj(lm_hidden))).detach() + self._perf.stop("prefill.stop_fn") + self._perf.start("prefill.dit_proj") dit_h = torch.cat([tts.lm_to_dit_proj(lm_hidden), tts.res_to_dit_proj(residual_hidden)], dim=-1) + self._perf.stop("prefill.dit_proj") self._setup_cfm_buffers() if self._enable_torch_compile: @@ -1921,24 +2564,29 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): pred_feat = self._run_cfm_for_state(state, dit_h, prefix_feat_cond.transpose(1, 2).contiguous()) + self._perf.start("prefill.feat_encoder") with torch.no_grad(): curr_embed = tts.enc_to_lm_proj(tts.feat_encoder(pred_feat.unsqueeze(1))).squeeze(1) + self._perf.stop("prefill.feat_encoder") self._commit_decode_state(state, stop_logits, curr_embed, pred_feat) state.decode_step_count = 0 state.request_start_time = time.perf_counter() state.prefill_completed = True + self._perf.stop("prefill_tail") if logger.isEnabledFor(logging.DEBUG): # Only compute the norm (which forces a GPU->CPU sync) if we will log it. logger.debug("PREFILL[%s]: patch norm=%.4f", state.request_id, pred_feat.norm().item()) + if self._enable_profiling: + logger.info("PREFILL[%s] tail breakdown:\n%s", state.request_id, self._perf.breakdown()) self._perf.reset() - def _finish_decode(self, state: _RequestState, meta: dict, res_out: torch.Tensor, dev: Any): + def _finish_decode(self, state: _RequestState, meta: _DecodeResidualMeta, res_out: torch.Tensor) -> None: self._perf.start("decode_step") tts = self.tts - lm_h = meta["new_lm_hidden"] + lm_h = meta.new_lm_hidden res_h = res_out.unsqueeze(0) if res_out.ndim == 1 else res_out dit_proj = getattr(self, "_compiled_dit_proj", None) or self._dit_proj_fn @@ -1959,7 +2607,11 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): if self._enable_profiling and state.decode_step_count % 20 == 0: logger.info("Step %d[%s]:\n%s", state.decode_step_count, state.request_id, self._perf.breakdown()) - def _finish_decode_batch(self, req_metas: list[tuple], batch_out: torch.Tensor): + def _finish_decode_batch( + self, + req_metas: list[tuple[_RequestState, bool, _DecodeResidualMeta]], + batch_out: torch.Tensor, + ) -> None: self._perf.start("decode_step") tts = self.tts @@ -1968,7 +2620,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): if self._enable_batched_cfm and not self._enable_cfm_cuda_graph: states = [state for state, _, _ in req_metas] - lm_h = torch.cat([meta["new_lm_hidden"] for _, _, meta in req_metas], dim=0) + lm_h = torch.cat([meta.new_lm_hidden for _, _, meta in req_metas], dim=0) pfc = torch.cat( [ state.curr_prefix_feat_cond.to(self._side_dtype).unsqueeze(0) @@ -2016,7 +2668,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): return for i, (state, _, meta) in enumerate(req_metas): - lm_h = meta["new_lm_hidden"] + lm_h = meta.new_lm_hidden res_h = batch_out[i : i + 1] pfc = state.curr_prefix_feat_cond.to(self._side_dtype) if pfc.ndim == 2: @@ -2474,7 +3126,10 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): # -------------------- preprocess / postprocess -------------------- def preprocess( - self, input_ids: torch.Tensor, input_embeds: torch.Tensor | None, **info_dict: Any + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor | None, + **info_dict: Unpack[VoxCPM2PreprocessInput], ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: additional = info_dict.get("additional_information") if isinstance(additional, dict): @@ -2570,34 +3225,28 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): ) if state.prompt_cache is None and (ref_audio or (prompt_audio and prompt_text)): - try: - state.prompt_cache = self._build_prompt_cache( - ref_audio=ref_audio, - prompt_audio=prompt_audio, - prompt_text=prompt_text, - ) - if ( - voice_name - and state.prompt_cache is not None - and state.prompt_cache.get("mode") == "reference" - and "ref_audio_feat" in state.prompt_cache - ): - _key = self._speaker_cache.make_cache_key( - voice_name, model_type="voxcpm2", created_at=_created_at - ) - self._speaker_cache.put(_key, {"ref_audio_feat": state.prompt_cache["ref_audio_feat"].cpu()}) - logger.debug("Speaker cache STORE for VoxCPM2 speaker '%s'", voice_name) - except Exception as e: - logger.warning("build_prompt_cache failed: %s; falling back to zero-shot", e) - state.prompt_cache = None + state.prompt_cache = self._build_prompt_cache( + ref_audio=ref_audio, + prompt_audio=prompt_audio, + prompt_text=prompt_text, + ) + if ( + voice_name + and state.prompt_cache is not None + and state.prompt_cache.get("mode") == "reference" + and "ref_audio_feat" in state.prompt_cache + ): + _key = self._speaker_cache.make_cache_key(voice_name, model_type="voxcpm2", created_at=_created_at) + self._speaker_cache.put(_key, {"ref_audio_feat": state.prompt_cache["ref_audio_feat"].cpu()}) + logger.debug("Speaker cache STORE for VoxCPM2 speaker '%s'", voice_name) inputs = self._build_prefill_inputs(token_ids, dev, req_id) tts = self.tts - feat_embed = tts.enc_to_lm_proj(tts.feat_encoder(inputs["audio_feat"])) - text_embed = self.model.embed_input_ids(inputs["text_token"].to(dev)) - text_mask, feat_mask = inputs["text_mask"], inputs["audio_mask"] + feat_embed = tts.enc_to_lm_proj(tts.feat_encoder(inputs.audio_feat)) + text_embed = self.model.embed_input_ids(inputs.text_token.to(dev)) + text_mask, feat_mask = inputs.text_mask, inputs.audio_mask embeds = (text_mask.unsqueeze(-1) * text_embed + feat_mask.unsqueeze(-1) * feat_embed).squeeze(0) - state.prefill_masks = (text_mask, feat_mask, inputs["audio_feat"], feat_embed) + state.prefill_masks = (text_mask, feat_mask, inputs.audio_feat, feat_embed) else: state = self._active_states.get(req_id) curr = state.curr_embed_for_next if state else None @@ -2609,23 +3258,31 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._pending_requests.append((req_id, is_prefill, embeds, span_len)) return input_ids, embeds, {} - def postprocess(self, hidden_states: torch.Tensor, **info: Any) -> dict[str, Any]: + def postprocess(self, hidden_states: torch.Tensor, **info: Unpack[VoxCPM2PostprocessInput]) -> dict[str, Any]: req_id = info.get("request_id", self._current_request_id or "default") if self._enable_profiling: state = self._active_states.get(req_id) if state and state.decode_step_count > 0: logger.info( - "REQUEST DONE[%s]: %d steps, %.2fs\n%s", + "REQUEST DONE[%s]: %d steps, %.2fs\n%s\nUnified graph: captures=%d replays=%d skips=%s", req_id, state.decode_step_count, time.perf_counter() - state.request_start_time, self._perf.breakdown(), + self._unified_graph_stats.captures, + self._unified_graph_stats.replays, + self._unified_graph_stats.skips, ) return {} # -------------------- build prefill inputs -------------------- - def _build_prefill_inputs(self, token_ids: list[int], dev: Any, req_id: str = "default") -> dict: + def _build_prefill_inputs( + self, + token_ids: list[int], + dev: torch.device, + req_id: str = "default", + ) -> _PrefillInputs: tts = self.tts dtype = self._side_dtype state = self._active_states.get(req_id) @@ -2673,12 +3330,12 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): text_mask = torch.cat([rtm.cpu(), ones_t, zeros_p]) audio_mask = torch.cat([ram.cpu(), zeros_t, ones_p]) - return { - "text_token": text_token.unsqueeze(0).to(dev), - "audio_feat": audio_feat.unsqueeze(0).to(dev).to(dtype), - "text_mask": text_mask.unsqueeze(0).to(dev), - "audio_mask": audio_mask.unsqueeze(0).to(dev), - } + return _PrefillInputs( + text_token=text_token.unsqueeze(0).to(dev), + audio_feat=audio_feat.unsqueeze(0).to(dev).to(dtype), + text_mask=text_mask.unsqueeze(0).to(dev), + audio_mask=audio_mask.unsqueeze(0).to(dev), + ) # -------------------- weight loading -------------------- diff --git a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py index b469234c1..43d2c9d19 100644 --- a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py @@ -621,8 +621,8 @@ class NPUARModelRunner(OmniNPUModelRunner): positions=positions, inputs_embeds=inputs_embeds, req_ids=req_ids[:num_reqs], - num_computed_tokens=[int(self.input_batch.num_computed_tokens_cpu[i]) for i in range(num_reqs)], - num_scheduled_tokens=[int(num_scheduled_tokens_np[i]) for i in range(num_reqs)], + num_computed_tokens=self.input_batch.num_computed_tokens_cpu[:num_reqs], + num_scheduled_tokens=num_scheduled_tokens_np[:num_reqs], input_ids_buffer=self.input_ids.gpu[:num_tokens_padded], ) # -------------------------------------- Omni-new ------------------------------------------------- @@ -690,8 +690,9 @@ class NPUARModelRunner(OmniNPUModelRunner): with record_function_or_nullcontext("post process"): # -------------------------------------- Omni-new ------------------------------------------------- # [Omni] Map pending ropes metadata to req_ids. - if hasattr(self.model, "flush_pending_metadata"): - self.model.flush_pending_metadata(list(req_ids)) + flush_pending_metadata = getattr(self.model, "flush_pending_metadata", None) + if callable(flush_pending_metadata): + flush_pending_metadata(req_ids[:num_reqs]) hidden_states, multimodal_outputs = self.extract_multimodal_outputs(hidden_states) diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index c1812b8c9..816246410 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -8,7 +8,7 @@ from __future__ import annotations import gc import threading -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from contextlib import nullcontext from copy import copy from dataclasses import replace @@ -46,6 +46,7 @@ from vllm_omni.outputs import OmniModelRunnerOutput from vllm_omni.utils.mm_outputs import build_mm_cpu, partition_payload_list, to_payload_element from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin +from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest logger = init_logger(__name__) @@ -588,6 +589,93 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): model = getattr(self, "model", None) return bool(getattr(model, "requires_full_prefix_cached_hidden_states", True)) + def _get_runner_assisted_full_attention_metadata_request( + self, + *, + req_ids: Sequence[str], + num_reqs: int, + num_reqs_padded: int, + num_scheduled_tokens_np: np.ndarray, + num_computed_tokens_cpu: torch.Tensor | np.ndarray, + max_num_scheduled_tokens: int, + ) -> RunnerAssistedFullAttentionMetadataRequest | None: + # Models without this hook keep the normal runner path. VoxCPM2 uses + # it to ask the runner for padded FULL attention metadata while keeping + # graph policy and lifecycle in the model layer. + hook = getattr(self.model, "get_runner_assisted_full_attention_metadata_request", None) + if not callable(hook): + return None + num_computed_tokens = num_computed_tokens_cpu + if hasattr(num_computed_tokens, "numpy"): + num_computed_tokens = num_computed_tokens.numpy() + request = hook( + req_ids=req_ids, + num_reqs=num_reqs, + num_scheduled_tokens=num_scheduled_tokens_np[:num_reqs], + num_computed_tokens=num_computed_tokens, + max_num_scheduled_tokens=max_num_scheduled_tokens, + ) + if request is None: + return None + if not isinstance(request, RunnerAssistedFullAttentionMetadataRequest): + raise TypeError( + "runner-assisted full attention metadata hook must return " + "RunnerAssistedFullAttentionMetadataRequest or None, got " + f"{type(request).__name__}" + ) + padded_num_reqs = max( + num_reqs_padded, + min(int(request.num_reqs_padded), self.scheduler_config.max_num_seqs), + ) + return RunnerAssistedFullAttentionMetadataRequest( + num_reqs_padded=padded_num_reqs, + for_cudagraph_capture=bool(request.for_cudagraph_capture), + ) + + def _refresh_runner_assisted_full_attention_metadata_buffers( + self, + *, + num_reqs: int, + num_reqs_padded: int, + num_scheduled_tokens_np: np.ndarray, + ) -> None: + num_computed_tokens = self.input_batch.num_computed_tokens_cpu[:num_reqs] + if hasattr(num_computed_tokens, "numpy"): + num_computed_tokens = num_computed_tokens.numpy() + np.add( + num_computed_tokens, + num_scheduled_tokens_np, + out=self.optimistic_seq_lens_cpu[:num_reqs].numpy(), + ) + self.optimistic_seq_lens_cpu[num_reqs:num_reqs_padded].fill_(0) + self.seq_lens.copy_(self.optimistic_seq_lens_cpu, non_blocking=True) + + cum_num_tokens = self._get_cumsum_and_arange( + num_scheduled_tokens_np, + self.query_pos.np, + ) + self.query_start_loc.np[0] = 0 + self.query_start_loc.np[1 : num_reqs + 1] = cum_num_tokens + self.query_start_loc.np[num_reqs + 1 : num_reqs_padded + 1].fill(cum_num_tokens[-1]) + self.query_start_loc.copy_to_gpu() + + self.input_batch.block_table.commit_block_table(num_reqs_padded) + + def _set_runner_assisted_full_attention_metadata_context( + self, + *, + enabled: bool, + num_reqs: int = 0, + ) -> bool: + hook = getattr(self.model, "set_runner_assisted_full_attention_metadata_context", None) + if not callable(hook): + return False + hook( + enabled=enabled, + num_reqs=num_reqs, + ) + return True + def _deferred_prefix_cache_mm_keys(self) -> set[str]: """Model-declared multimodal keys whose prefix-cache writes are deferred.""" model = getattr(self, "model", None) @@ -1028,6 +1116,26 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): ) num_tokens_padded = batch_desc.num_tokens num_reqs_padded = batch_desc.num_reqs if batch_desc.num_reqs is not None else num_reqs + num_computed_tokens_cpu = self.input_batch.num_computed_tokens_cpu[:num_reqs] + + runner_assisted_full_attn_request = self._get_runner_assisted_full_attention_metadata_request( + req_ids=req_ids[:num_reqs], + num_reqs=num_reqs, + num_reqs_padded=num_reqs_padded, + num_scheduled_tokens_np=num_scheduled_tokens_np, + num_computed_tokens_cpu=num_computed_tokens_cpu, + max_num_scheduled_tokens=max_num_scheduled_tokens, + ) + runner_assisted_full_attn_capture = False + if runner_assisted_full_attn_request is not None: + num_reqs_padded = runner_assisted_full_attn_request.num_reqs_padded + runner_assisted_full_attn_capture = runner_assisted_full_attn_request.for_cudagraph_capture + num_tokens_padded = max( + num_tokens_padded, + num_reqs_padded * max_num_scheduled_tokens, + ) + runner_assisted_full_attn = runner_assisted_full_attn_request is not None + ubatch_slices, ubatch_slices_padded = maybe_create_ubatch_slices( should_ubatch, num_scheduled_tokens_np, @@ -1035,8 +1143,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): num_reqs_padded, self.parallel_config.num_ubatches, ) - - pad_attn = cudagraph_mode == CUDAGraphMode.FULL + pad_attn = cudagraph_mode == CUDAGraphMode.FULL or runner_assisted_full_attn use_spec_decode = len(scheduler_output.scheduled_spec_decode_tokens) > 0 ubatch_slices_attn = ubatch_slices_padded if pad_attn else ubatch_slices @@ -1058,11 +1165,18 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): num_tokens_unpadded=num_tokens_unpadded, ubatch_slices=ubatch_slices_padded, ) + if runner_assisted_full_attn: + self._refresh_runner_assisted_full_attention_metadata_buffers( + num_reqs=num_reqs, + num_reqs_padded=num_reqs_padded, + num_scheduled_tokens_np=num_scheduled_tokens_np, + ) + attn_num_reqs = num_reqs_padded if runner_assisted_full_attn else num_reqs attn_metadata, spec_decode_common_attn_metadata = self._build_attention_metadata( num_tokens=num_tokens_unpadded, num_tokens_padded=num_tokens_padded if pad_attn else None, - num_reqs=num_reqs, + num_reqs=attn_num_reqs, num_reqs_padded=num_reqs_padded if pad_attn else None, max_query_len=max_num_scheduled_tokens, ubatch_slices=ubatch_slices_attn, @@ -1071,6 +1185,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): num_scheduled_tokens=scheduler_output.num_scheduled_tokens, cascade_attn_prefix_lens=cascade_attn_prefix_lens, slot_mappings=slot_mappings_by_group, + for_cudagraph_capture=runner_assisted_full_attn_capture, ) self._maybe_attach_attention_metadata_extensions( attn_metadata=attn_metadata, @@ -1079,6 +1194,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): max_query_len=max_num_scheduled_tokens, pad_attn=pad_attn, num_scheduled_tokens_np=num_scheduled_tokens_np, + for_cudagraph_capture=runner_assisted_full_attn_capture, ) ( @@ -1092,14 +1208,15 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): # Let the model adjust inputs before forward (e.g. restore input_ids # for multimodal position detection, fix decode position offsets). - if hasattr(self.model, "prepare_runner_inputs"): - input_ids, positions = self.model.prepare_runner_inputs( + prepare_runner_inputs = getattr(self.model, "prepare_runner_inputs", None) + if callable(prepare_runner_inputs): + input_ids, positions = prepare_runner_inputs( input_ids=input_ids, positions=positions, inputs_embeds=inputs_embeds, req_ids=req_ids[:num_reqs], - num_computed_tokens=[int(self.input_batch.num_computed_tokens_cpu[i]) for i in range(num_reqs)], - num_scheduled_tokens=[int(num_scheduled_tokens_np[i]) for i in range(num_reqs)], + num_computed_tokens=self.input_batch.num_computed_tokens_cpu[:num_reqs], + num_scheduled_tokens=num_scheduled_tokens_np[:num_reqs], input_ids_buffer=self.input_ids.gpu[:num_tokens_padded], ) @@ -1108,46 +1225,60 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): # with CUDA graph capture. if self.calculate_kv_scales: cudagraph_mode = CUDAGraphMode.NONE + runner_assisted_full_attn = False # Mark KV scales as calculated after the first forward pass self.calculate_kv_scales = False + runner_assisted_context_enabled = False + if runner_assisted_full_attn: + runner_assisted_context_enabled = self._set_runner_assisted_full_attention_metadata_context( + enabled=True, + num_reqs=num_reqs, + ) + runner_assisted_full_attn = runner_assisted_context_enabled + # Run the model. # Use persistent buffers for CUDA graphs. # When spec decode is enabled, defer connector finalization # (wait_for_save + clear metadata) until after draft model runs. defer_kv_connector_finalize = self.speculative_config is not None - with ( - nullcontext(), - set_forward_context( - attn_metadata, - self.vllm_config, - num_tokens=num_tokens_padded, - num_tokens_across_dp=num_tokens_across_dp, - cudagraph_runtime_mode=cudagraph_mode, - batch_descriptor=batch_desc, - ubatch_slices=ubatch_slices_padded, - slot_mapping=slot_mappings, # OMNI: required for KV cache operations - ), - record_function_or_nullcontext("gpu_model_runner: forward"), - self.maybe_get_kv_connector_output( - scheduler_output, - defer_finalize=defer_kv_connector_finalize, - ) as kv_connector_output, - ): - model_output = self._model_forward( - input_ids=input_ids, - positions=positions, - intermediate_tensors=intermediate_tensors, - inputs_embeds=inputs_embeds, - **model_kwargs, - sampling_metadata=self.input_batch.sampling_metadata, - logits_index=logits_indices, - sampler=self.sampler, - ) + try: + with ( + nullcontext(), + set_forward_context( + attn_metadata, + self.vllm_config, + num_tokens=num_tokens_padded, + num_tokens_across_dp=num_tokens_across_dp, + cudagraph_runtime_mode=(CUDAGraphMode.FULL if runner_assisted_full_attn else cudagraph_mode), + batch_descriptor=batch_desc, + ubatch_slices=ubatch_slices_padded, + slot_mapping=slot_mappings, # OMNI: required for KV cache operations + ), + record_function_or_nullcontext("gpu_model_runner: forward"), + self.maybe_get_kv_connector_output( + scheduler_output, + defer_finalize=defer_kv_connector_finalize, + ) as kv_connector_output, + ): + model_output = self._model_forward( + input_ids=input_ids, + positions=positions, + intermediate_tensors=intermediate_tensors, + inputs_embeds=inputs_embeds, + **model_kwargs, + sampling_metadata=self.input_batch.sampling_metadata, + logits_index=logits_indices, + sampler=self.sampler, + ) - # [Omni] Map pending ropes metadata to req_ids. - if hasattr(self.model, "flush_pending_metadata"): - self.model.flush_pending_metadata(list(req_ids)) + # [Omni] Map pending ropes metadata to req_ids. + flush_pending_metadata = getattr(self.model, "flush_pending_metadata", None) + if callable(flush_pending_metadata): + flush_pending_metadata(req_ids[:num_reqs]) + finally: + if runner_assisted_context_enabled: + self._set_runner_assisted_full_attention_metadata_context(enabled=False) with record_function_or_nullcontext("gpu_model_runner: postprocess"): if self.use_aux_hidden_state_outputs: diff --git a/vllm_omni/worker/runner_assisted_metadata.py b/vllm_omni/worker/runner_assisted_metadata.py new file mode 100644 index 000000000..a71180a25 --- /dev/null +++ b/vllm_omni/worker/runner_assisted_metadata.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +from collections.abc import Sequence +from typing import NamedTuple, Protocol + + +class RunnerAssistedFullAttentionMetadataRequest(NamedTuple): + num_reqs_padded: int + for_cudagraph_capture: bool + + +class RunnerAssistedAttentionMetadataProvider(Protocol): + """Optional model hook for runner-built full attention metadata.""" + + def get_runner_assisted_full_attention_metadata_request( + self, + *, + req_ids: Sequence[str], + num_reqs: int, + num_scheduled_tokens: Sequence[int], + num_computed_tokens: Sequence[int], + max_num_scheduled_tokens: int, + ) -> RunnerAssistedFullAttentionMetadataRequest | None: ... + + def set_runner_assisted_full_attention_metadata_context( + self, + *, + enabled: bool, + num_reqs: int = 0, + ) -> None: ... From 29dcdec66d279f5ac40c3bd499a480e6e5126166 Mon Sep 17 00:00:00 2001 From: boatman <109857087+sphinxkkkbc@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:39:20 +0800 Subject: [PATCH 022/101] [Cleanup]Replace legacy logging branches with PreparedRequest.model_type (#4862) Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com> --- .../openai_api/test_serving_speech.py | 35 +++++++++++++++++ .../entrypoints/openai/serving_speech.py | 38 ++++--------------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 0c2006536..bed775ee2 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -39,6 +39,7 @@ from vllm_omni.entrypoints.openai.serving_speech import ( OmniOpenAIServingSpeech, _create_wav_header, ) +from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( FISH_TEXT_ONLY_SYSTEM_PROMPT, build_fish_voice_clone_prompt_ids, @@ -3343,6 +3344,40 @@ class TestTTSAsyncOffloading: qwen3_tts_server._build_tts_params.assert_called_once() qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once() + def test_prepare_speech_generation_uses_adapter_model_type_label( + self, + voxtral_server, + mocker: MockerFixture, + ): + """Adapter model_type should replace the legacy _tts_model_type label ladder.""" + legacy_tts_model_type = "dummy_tts" + adapter_model_type = "adapter_dummy_tts" + + class FakeAdapter: + def validate(self, request): + return None + + async def build(self, request, sampling_params_list, has_inline_ref_audio): + return PreparedRequest( + prompt={"prompt": request.input}, + tts_params={}, + model_type=adapter_model_type, + ) + + voxtral_server._tts_model_type = legacy_tts_model_type + mocker.patch.object(voxtral_server, "_get_tts_adapter", return_value=FakeAdapter()) + log_info = mocker.patch("vllm_omni.entrypoints.openai.serving_speech.logger.info") + + asyncio.run(voxtral_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="hello"))) + + assert adapter_model_type != legacy_tts_model_type + assert any( + call.args + and call.args[0] == "TTS speech request %s: text=%r, model=%s" + and call.args[3] == adapter_model_type + for call in log_info.call_args_list + ) + def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream_format=sse should request delta-style multimodal outputs.""" qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 72654738f..6f8f1e6dc 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -3513,11 +3513,13 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # apply uploaded speakers inside validate(), which sets request.ref_audio # in place. The builders need to know whether the caller supplied audio # inline vs. via an uploaded voice. + model_type: str | None = None has_inline_ref_audio = request.ref_audio is not None if self._tts_model_type == "ming_flash_omni_tts": # ming_flash_omni is intentionally NOT migrated onto the adapter # framework in this PR (it has no registered adapter); keep it on the # legacy inline dispatch so serving still works. + model_type = "ming_flash_omni_tts" validation_error = self._validate_ming_flash_omni_tts_request(request) if validation_error: raise ValueError(validation_error) @@ -3531,6 +3533,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): prepared = await adapter.build(request, sampling_params_list, has_inline_ref_audio) prompt = prepared.prompt tts_params = prepared.tts_params + model_type = prepared.model_type qwen3_ref_audio_warmup_artifact_key = prepared.warmup_artifact_key else: # Qwen omni models (Qwen3-Omni, Qwen2.5-Omni) use a "talker" @@ -3554,36 +3557,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): tts_params = {} prompt = {"prompt": request.input} - if self._tts_model_type == "step_audio2": - model_type = "step_audio2" - elif self._is_fish_speech: - model_type = "fish_speech" - elif self._tts_model_type == "covo_audio": - model_type = "covo_audio" - elif self._tts_model_type == "voxtral_tts": - model_type = "voxtral_tts" - elif self._tts_model_type == "cosyvoice3": - model_type = "cosyvoice3" - elif self._tts_model_type == "voxcpm2": - 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": - model_type = "moss_tts" - elif self._tts_model_type == "higgs_audio_v2": - model_type = "higgs_audio_v2" - elif self._tts_model_type == "glm_tts": - model_type = "glm_tts" - elif self._tts_model_type == "indextts2": - model_type = "indextts2" - elif self._is_tts: - model_type = tts_params.get("task_type", ["unknown"])[0] - else: - model_type = "generic" + if model_type is None: + if self._is_tts: + model_type = tts_params.get("task_type", ["unknown"])[0] + else: + model_type = "generic" logger.info( "TTS speech request %s: text=%r, model=%s", request_id, From 0798b0bf03cb6e7c4dc40ee58fe12ce492ce255f Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Sun, 5 Jul 2026 01:47:11 +0800 Subject: [PATCH 023/101] [Bugfix][Qwen3-TTS] Seed residual MTP sampling (#4869) Signed-off-by: Sy03 <1370724210@qq.com> Co-authored-by: Yueqian Lin --- .../openai_api/test_serving_speech.py | 21 ++++++++++++++ .../deploy/qwen3_tts_high_concurrency.yaml | 2 -- vllm_omni/deploy/voxcpm2.yaml | 29 ++++++++++--------- .../entrypoints/openai/serving_speech.py | 12 ++++++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index bed775ee2..8f2797bc7 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -3344,6 +3344,27 @@ class TestTTSAsyncOffloading: qwen3_tts_server._build_tts_params.assert_called_once() qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once() + def test_prepare_speech_generation_qwen3_default_seed_sets_tts_local_seed( + self, qwen3_tts_server, mocker: MockerFixture + ): + """Deploy default seed should seed Qwen3 TTS residual MTP sampling.""" + qwen3_tts_server.engine_client.default_sampling_params_list = [ + SimpleNamespace(max_tokens=2048, seed=42, extra_args=None) + ] + qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._build_tts_params = mocker.MagicMock( + return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} + ) + qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + request = OpenAICreateSpeechRequest(input="hello") + + asyncio.run(qwen3_tts_server._prepare_speech_generation(request)) + + stage0_params = qwen3_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"][0] + assert stage0_params.seed == 42 + assert stage0_params.extra_args["tts_local_seed"] == 42 + assert qwen3_tts_server.engine_client.default_sampling_params_list[0].extra_args is None + def test_prepare_speech_generation_uses_adapter_model_type_label( self, voxtral_server, diff --git a/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml b/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml index 9c6ec19fe..4fb6f9894 100644 --- a/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml +++ b/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml @@ -60,7 +60,6 @@ stages: temperature: 0.9 top_k: 50 max_tokens: 4096 - seed: 42 repetition_penalty: 1.05 subtalker_sampling_params: do_sample: true @@ -85,7 +84,6 @@ stages: top_p: 1.0 top_k: -1 max_tokens: 65536 - seed: 42 repetition_penalty: 1.0 platforms: diff --git a/vllm_omni/deploy/voxcpm2.yaml b/vllm_omni/deploy/voxcpm2.yaml index d534a41c1..63996f1b7 100644 --- a/vllm_omni/deploy/voxcpm2.yaml +++ b/vllm_omni/deploy/voxcpm2.yaml @@ -28,20 +28,21 @@ stages: max_num_batched_tokens: 4096 max_model_len: 4096 devices: "0" - hf_overrides: - voxcpm2_runtime_config: - audio_emit_every: 1 - vae_decode_every: 3 - enable_batched_cfm: true - enable_batched_vae_decode: true - coalesce_audio_d2h: true - enable_loc_dit_fused_qkv: true - enable_loc_dit_fused_mlp: true - enable_loc_dit_zero_dt_cache: true - enable_loc_dit_skip_qkv_contig: true - enable_loc_dit_reduce_overhead_no_cg: true - enable_unified_decode_graph: true - unified_decode_graph_max_batch_size: 8 + engine_extras: + hf_overrides: + voxcpm2_runtime_config: + audio_emit_every: 1 + vae_decode_every: 3 + enable_batched_cfm: true + enable_batched_vae_decode: true + coalesce_audio_d2h: true + enable_loc_dit_fused_qkv: true + enable_loc_dit_fused_mlp: true + enable_loc_dit_zero_dt_cache: true + enable_loc_dit_skip_qkv_contig: true + enable_loc_dit_reduce_overhead_no_cg: true + enable_unified_decode_graph: true + unified_decode_graph_max_batch_size: 8 default_sampling_params: temperature: 0.0 top_p: 1.0 diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 6f8f1e6dc..ae1a7c1fd 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -3687,6 +3687,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): stage0_params.extra_args = {} stage0_params.extra_args["tts_local_seed"] = request.seed + if self._tts_model_type == "qwen3_tts" and sampling_params_list: + stage0_params = sampling_params_list[0] + default_seed = getattr(stage0_params, "seed", None) + if default_seed is not None: + import copy + + sampling_params_list = copy.deepcopy(sampling_params_list) + stage0_params = sampling_params_list[0] + if stage0_params.extra_args is None: + stage0_params.extra_args = {} + stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed)) + generator = self.engine_client.generate( prompt=prompt, request_id=request_id, From 1b31f96240229b9366219b3f97fff7ec00844843 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Sun, 5 Jul 2026 03:57:55 +0800 Subject: [PATCH 024/101] [Bugfix] Fix Qwen3-TTS no-async-chunk payload splitting (#4870) Signed-off-by: Sy03 <1370724210@qq.com> Signed-off-by: Yueqian Lin Co-authored-by: Yueqian Lin --- .../openai_api/test_serving_speech.py | 42 ++++++++++++++++ tests/worker/test_gpu_ar_model_runner.py | 50 +++++++++++++++++-- .../entrypoints/openai/serving_speech.py | 14 ++++-- vllm_omni/worker/gpu_ar_model_runner.py | 6 +-- 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 8f2797bc7..efed9980e 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -3450,6 +3450,48 @@ class TestTTSAsyncOffloading: mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, True) + def test_prepare_speech_generation_no_async_chunk_stream_uses_final_only( + self, qwen3_tts_server, mocker: MockerFixture + ): + """Full-payload TTS streaming should not request delta multimodal outputs.""" + qwen3_tts_server.engine_client.model_config.async_chunk = False + qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) + qwen3_tts_server._build_tts_params = mocker.MagicMock( + return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} + ) + qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + mock_coerce = mocker.patch( + "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", + return_value=qwen3_tts_server.engine_client.default_sampling_params_list, + ) + request = OpenAICreateSpeechRequest(input="hello", stream_format="audio", response_format="pcm") + + asyncio.run(qwen3_tts_server._prepare_speech_generation(request)) + + mock_coerce.assert_called_once_with(qwen3_tts_server.engine_client.default_sampling_params_list, False) + + def test_prepare_speech_generation_no_async_chunk_stream_keeps_delta_for_non_qwen3( + self, voxtral_server, mocker: MockerFixture + ): + """FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only.""" + voxtral_server.engine_client.model_config.async_chunk = False + mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None) + voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock( + return_value={ + "prompt_token_ids": [1, 2, 3], + "additional_information": {"voice": ["test"]}, + } + ) + mock_coerce = mocker.patch( + "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", + return_value=voxtral_server.engine_client.default_sampling_params_list, + ) + request = OpenAICreateSpeechRequest(input="hello", voice="test", stream_format="audio", response_format="pcm") + + asyncio.run(voxtral_server._prepare_speech_generation(request)) + + mock_coerce.assert_called_once_with(voxtral_server.engine_client.default_sampling_params_list, True) + def test_prepare_speech_generation_qwen3_voicedesign_non_streaming_mode_false( self, qwen3_tts_server, mocker: MockerFixture ): diff --git a/tests/worker/test_gpu_ar_model_runner.py b/tests/worker/test_gpu_ar_model_runner.py index 0e3a39abc..b56917fdb 100644 --- a/tests/worker/test_gpu_ar_model_runner.py +++ b/tests/worker/test_gpu_ar_model_runner.py @@ -266,7 +266,6 @@ def test_build_omni_output_uses_snapshots_and_connector_after_accumulation(monke ec_connector_output=None, cudagraph_stats=None, kv_extracted_req_ids=["r2"], - seq_len=3, num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), ) @@ -312,7 +311,6 @@ def test_build_omni_output_copies_hidden_for_partial_downstream_batch(monkeypatc ec_connector_output=None, cudagraph_stats=None, kv_extracted_req_ids=None, - seq_len=6, num_scheduled_tokens_np=np.array([1, 2, 3], dtype=np.int32), query_start_loc_cpu=torch.tensor([0, 1, 3], dtype=torch.long), ) @@ -366,7 +364,6 @@ def test_process_additional_information_uses_snapshot_request_order(monkeypatch) ec_connector_output=None, cudagraph_stats=None, kv_extracted_req_ids=None, - seq_len=3, num_scheduled_tokens_np=torch.tensor([1, 2], dtype=torch.int32).numpy(), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), ) @@ -427,7 +424,6 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch): ec_connector_output=None, cudagraph_stats=None, kv_extracted_req_ids=None, - seq_len=2, num_scheduled_tokens_np=np.array([2], dtype=np.int32), query_start_loc_cpu=torch.tensor([0], dtype=torch.long), ) @@ -439,6 +435,51 @@ def test_build_omni_output_skips_hidden_when_model_opts_out(monkeypatch): assert output.multimodal_outputs is None +def test_build_omni_output_splits_mm_by_scheduled_tokens_when_hidden_is_tail_only(monkeypatch): + runner = _make_async_output_runner(engine_output_type="latent") + runner.model.omni_pooler_payload_include_hidden = False + runner._async_chunk = False + runner.requests = {"r1": object(), "r2": object(), "r3": object()} + + monkeypatch.setattr( + GPUARModelRunner, + "_resolve_pooler_payload_req_ids", + lambda self, req_ids: ("latent", req_ids), + ) + monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False) + monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None) + monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None) + + codes = torch.arange(48, dtype=torch.long).reshape(3, 16) + output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot( + runner, + scheduler_output=SimpleNamespace( + total_num_scheduled_tokens=3, + num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1}, + ), + hidden_states=torch.tensor([[1.0]]), + staged_hidden_states_cpu=None, + multimodal_outputs={"codes": {"audio": codes}}, + req_ids_output_copy=["r1", "r2", "r3"], + req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2}, + valid_sampled_token_ids=[[101], [102], [103]], + logprobs_lists=None, + prompt_logprobs_dict={}, + num_nans_in_logits=None, + kv_connector_output=None, + ec_connector_output=None, + cudagraph_stats=None, + kv_extracted_req_ids=None, + num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32), + query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long), + ) + + assert output.inter_stage_outputs is not None + assert torch.equal(output.inter_stage_outputs[0]["codes.audio"], codes[0:1]) + assert torch.equal(output.inter_stage_outputs[1]["codes.audio"], codes[1:2]) + assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], codes[2:3]) + + def test_async_snapshot_payload_omits_hidden_when_model_opts_out(): runner = _make_async_output_runner() runner.model.omni_pooler_payload_include_hidden = False @@ -627,7 +668,6 @@ def test_build_omni_output_falls_back_to_mm_cpu_without_prefix_merge(monkeypatch ec_connector_output=None, cudagraph_stats=None, kv_extracted_req_ids=None, - seq_len=2, num_scheduled_tokens_np=np.array([1, 1], dtype=np.int32), query_start_loc_cpu=torch.tensor([0, 1], dtype=torch.long), ) diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index ae1a7c1fd..6bfedccba 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -3495,12 +3495,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): request_id = request_id or f"speech-{random_uuid()}" qwen3_ref_audio_warmup_artifact_key: str | None = None - # If this is a streaming request, we need to coerce - # cumulative outputs to delta outputs; this ensures - # we don't emit redundant MM data & drain after emitting. + # If this is a streaming request with real async chunks, we need to + # coerce cumulative outputs to delta outputs; this ensures we don't + # emit redundant MM data & drain after emitting. Qwen3-TTS full-payload + # (async_chunk=False) has no incremental audio chunks, so keep + # FINAL_ONLY semantics and let the streaming response send the final + # waveform once. Scoped to qwen3_tts: other async_chunk=False models + # keep the DELTA coercion they stream with today. # list() makes a copy to avoid mutating the params. sampling_params_list = list(self.engine_client.default_sampling_params_list) - is_streaming_request = request.is_streaming() + async_chunk = getattr(self.model_config, "async_chunk", True) + qwen3_full_payload = self._tts_model_type == "qwen3_tts" and not bool(async_chunk) + is_streaming_request = request.is_streaming() and not qwen3_full_payload sampling_params_list = coerce_param_message_types(sampling_params_list, is_streaming_request) # Build prompt + tts_params via the per-model adapter (RFC #4327). Every diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 816246410..2b44e7b45 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -1676,7 +1676,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): ec_connector_output: Any, cudagraph_stats: Any, kv_extracted_req_ids: list[str] | None, - seq_len: int, num_scheduled_tokens_np: np.ndarray, query_start_loc_cpu: Any, postprocess_already_applied: bool = False, @@ -1732,6 +1731,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): # The actual multimodal wire transport uses multimodal_outputs instead. pooler_output: list[dict[str, object]] | None = None if needs_pooler_payload: + mm_seq_len = int(scheduler_output.total_num_scheduled_tokens) mm_cpu = None if self.omni_prefix_cache is not None: ( @@ -1787,7 +1787,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): mm_cpu=mm_cpu, audio_sparse_output=audio_sparse_output, sparse_mm_index=sparse_mm_index, - seq_len=seq_len, + seq_len=mm_seq_len, ) pooler_output.append(flatten_payload(payload)) @@ -1873,7 +1873,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): slot_mappings, # OMNI: unpack slot_mappings for drafter ) = self.execute_model_state self.execute_model_state = None - seq_len = hidden_states.shape[0] # Apply structured output bitmasks if present. if grammar_output is not None: @@ -2040,7 +2039,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): ec_connector_output=ec_connector_output, cudagraph_stats=cudagraph_stats, kv_extracted_req_ids=kv_extracted_req_ids, - seq_len=seq_len, num_scheduled_tokens_np=num_scheduled_tokens_np, query_start_loc_cpu=query_start_loc_cpu, postprocess_already_applied=omni_postprocess_already_applied, From aa21412c011cbbdace7a82941f0a3cf18ed48054 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Sun, 5 Jul 2026 03:58:21 +0800 Subject: [PATCH 025/101] [Fix][VoxCPM2] Follow up unified graph config gate (#4878) Signed-off-by: Sy03 <1370724210@qq.com> --- ...ni_ar_scheduler_unified_decode_deferral.py | 62 ++++++++- ...st_runner_assisted_unified_graph_static.py | 10 +- .../models/voxcpm2/runtime_config.py | 123 ++++++++++++++++++ .../models/voxcpm2/scheduler.py | 10 +- .../models/voxcpm2/voxcpm2_talker.py | 114 +--------------- 5 files changed, 194 insertions(+), 125 deletions(-) create mode 100644 vllm_omni/model_executor/models/voxcpm2/runtime_config.py diff --git a/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py b/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py index 8192a7b06..27087d253 100644 --- a/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py +++ b/tests/core/sched/test_omni_ar_scheduler_unified_decode_deferral.py @@ -6,6 +6,7 @@ import pytest from vllm.v1.request import RequestStatus import vllm_omni.core.sched.omni_ar_scheduler as scheduler_mod +import vllm_omni.model_executor.models.voxcpm2.scheduler as voxcpm2_scheduler_mod from vllm_omni.model_executor.models.voxcpm2.scheduler import VoxCPM2OmniARAsyncScheduler pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -51,14 +52,30 @@ class _MockRequest: return RequestStatus.is_finished(self.status) -def _make_scheduler(*, enable_unified_decode_graph: bool = True) -> VoxCPM2OmniARAsyncScheduler: +@pytest.fixture(autouse=True) +def _mock_cuda_graph_platform(monkeypatch) -> None: + monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: True) + + +def _make_scheduler( + *, + enable_unified_decode_graph: bool | None = True, + deterministic_cfm_noise: bool = False, + runtime_config_on_model_config: bool = False, +) -> VoxCPM2OmniARAsyncScheduler: sched = VoxCPM2OmniARAsyncScheduler.__new__(VoxCPM2OmniARAsyncScheduler) - runtime_config = SimpleNamespace(enable_unified_decode_graph=enable_unified_decode_graph) - sched.vllm_config = SimpleNamespace( - model_config=SimpleNamespace( - hf_config=SimpleNamespace(voxcpm2_runtime_config=runtime_config), + hf_config = SimpleNamespace() + model_config = SimpleNamespace(hf_config=hf_config) + if enable_unified_decode_graph is not None or deterministic_cfm_noise: + runtime_config = SimpleNamespace( + enable_unified_decode_graph=True if enable_unified_decode_graph is None else enable_unified_decode_graph, + deterministic_cfm_noise=deterministic_cfm_noise, ) - ) + if runtime_config_on_model_config: + model_config.voxcpm2_runtime_config = runtime_config + else: + hf_config.voxcpm2_runtime_config = runtime_config + sched.vllm_config = SimpleNamespace(model_config=model_config) return sched @@ -86,6 +103,39 @@ def test_voxcpm2_unified_decode_graph_does_not_defer_when_disabled() -> None: assert not scheduler._should_defer_waiting_for_unified_decode_graph() +def test_voxcpm2_unified_decode_graph_uses_model_runtime_defaults() -> None: + scheduler = _make_scheduler(enable_unified_decode_graph=None) + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_voxcpm2_unified_decode_graph_reads_model_config_runtime_config() -> None: + scheduler = _make_scheduler(enable_unified_decode_graph=True, runtime_config_on_model_config=True) + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_voxcpm2_unified_decode_graph_does_not_defer_with_deterministic_noise() -> None: + scheduler = _make_scheduler(deterministic_cfm_noise=True) + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert not scheduler._should_defer_waiting_for_unified_decode_graph() + + +def test_voxcpm2_unified_decode_graph_does_not_defer_without_cuda_graph(monkeypatch) -> None: + monkeypatch.setattr(voxcpm2_scheduler_mod.current_omni_platform, "is_cuda", lambda: False) + scheduler = _make_scheduler() + scheduler.running = [_MockRequest("decode")] + scheduler.waiting = _MockQueue([_MockRequest("prefill", status=RequestStatus.WAITING)]) + + assert not scheduler._should_defer_waiting_for_unified_decode_graph() + + def test_unified_decode_graph_deferral_restores_waiting_queue(monkeypatch) -> None: scheduler = _make_scheduler() scheduler.running = [_MockRequest("decode")] diff --git a/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py b/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py index 33ac80a6f..8154ddd65 100644 --- a/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py +++ b/tests/model_executor/models/voxcpm2/test_runner_assisted_unified_graph_static.py @@ -12,6 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4] RUNNER = REPO_ROOT / "vllm_omni" / "worker" / "gpu_ar_model_runner.py" RUNNER_ASSISTED_METADATA = REPO_ROOT / "vllm_omni" / "worker" / "runner_assisted_metadata.py" TALKER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "voxcpm2_talker.py" +VOXCPM2_RUNTIME_CONFIG = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "runtime_config.py" VOXCPM2_PIPELINE = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "pipeline.py" VOXCPM2_SCHEDULER = REPO_ROOT / "vllm_omni" / "model_executor" / "models" / "voxcpm2" / "scheduler.py" OMNI_AR_SCHEDULER = REPO_ROOT / "vllm_omni" / "core" / "sched" / "omni_ar_scheduler.py" @@ -74,6 +75,7 @@ def test_ar_runner_without_model_hook_stays_on_normal_path(): def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise(): source = TALKER.read_text() + runtime_config_source = VOXCPM2_RUNTIME_CONFIG.read_text() compact_source = "".join(source.split()) assert "_voxcpm2_compile_without_inductor_cudagraphs" in source @@ -86,8 +88,8 @@ def test_voxcpm2_graph_paths_fail_closed_and_preserve_deterministic_noise(): assert '"triton.cudagraphs": False' in compile_source assert '"triton.cudagraph_trees": False' in compile_source - assert "self._enable_unified_decode_graph=(use_cuda_graph" in compact_source - assert "andnotself._deterministic_cfm_noise" in compact_source + assert "self._enable_unified_decode_graph=self._runtime_config.unified_decode_graph_available(" in compact_source + assert "andnotself.deterministic_cfm_noise" in "".join(runtime_config_source.split()) assert "decode_tail_graph" not in source unified_source = source[source.index("def _forward_unified_decode") :] @@ -183,7 +185,9 @@ def test_voxcpm2_scheduler_policy_stays_model_local(): assert "def _should_defer_waiting_admission(self) -> bool:" in common_source assert "class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler)" in voxcpm2_scheduler_source - assert "voxcpm2_runtime_config" in voxcpm2_scheduler_source + assert "from .runtime_config import _VoxCPM2RuntimeConfig" in voxcpm2_scheduler_source + assert "_VoxCPM2RuntimeConfig.from_vllm_config(self.vllm_config)" in voxcpm2_scheduler_source + assert "unified_decode_graph_available(use_cuda_graph=current_omni_platform.is_cuda())" in voxcpm2_scheduler_source assert "_should_defer_waiting_for_unified_decode_graph" in voxcpm2_scheduler_source assert "def schedule(" not in voxcpm2_scheduler_source assert "create_request_queue" not in voxcpm2_scheduler_source diff --git a/vllm_omni/model_executor/models/voxcpm2/runtime_config.py b/vllm_omni/model_executor/models/voxcpm2/runtime_config.py new file mode 100644 index 000000000..1446729e7 --- /dev/null +++ b/vllm_omni/model_executor/models/voxcpm2/runtime_config.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import dataclasses +from typing import Any + +from vllm.logger import init_logger + +logger = init_logger(__name__) + + +@dataclasses.dataclass(frozen=True) +class _VoxCPM2RuntimeConfig: + enable_profiling: bool = False + enable_nvtx_profile: bool = False + enable_loc_dit_layer_nvtx: bool = False + enable_loc_dit_fused_qkv: bool = True + enable_loc_dit_fused_mlp: bool = True + enable_loc_dit_zero_dt_cache: bool = True + enable_loc_dit_fast_rope: bool = False + enable_loc_dit_skip_qkv_contig: bool = True + enable_loc_dit_reduce_overhead_no_cg: bool = False + enable_loc_dit_fullgraph_no_cg: bool = False + cfg_cutoff_ratio: float = 1.0 + decode_graph_capture_policy: str = "all" + enable_vae_cuda_graph: bool = False + enable_cfm_cuda_graph: bool = False + enable_cfm_prealloc_output: bool = False + enable_batched_cfm: bool = True + deterministic_cfm_noise: bool = False + deterministic_cfm_seed: int = 20260601 + audio_emit_every: int = 1 + vae_decode_every: int = 3 + enable_delayed_audio_copy: bool = False + delayed_audio_copy_use_events: bool = False + coalesce_audio_d2h: bool = True + enable_batched_vae_decode: bool = True + enable_batched_fsq_fusion: bool = False + batched_fsq_fusion_max_batch: int = 32 + enable_batched_prefill_tail: bool = False + enable_unified_decode_graph: bool = True + unified_decode_graph_max_batch_size: int = 64 + + @classmethod + def from_vllm_config(cls, vllm_config: Any) -> _VoxCPM2RuntimeConfig: + model_config = vllm_config.model_config + raw = getattr(model_config, "voxcpm2_runtime_config", None) + if raw is None: + raw = getattr(getattr(model_config, "hf_config", None), "voxcpm2_runtime_config", None) + if raw is None: + return cls() + if hasattr(raw, "to_dict"): + raw = raw.to_dict() + elif not isinstance(raw, dict) and hasattr(raw, "__dict__"): + raw = vars(raw) + if not isinstance(raw, dict): + logger.warning("Ignoring invalid voxcpm2_runtime_config=%r; expected a dict.", raw) + return cls() + + fields = {field.name: field for field in dataclasses.fields(cls)} + values: dict[str, Any] = {} + for key, value in raw.items(): + if key not in fields: + logger.warning("Ignoring unknown VoxCPM2 runtime config key: %s", key) + continue + default = getattr(cls(), key) + values[key] = cls._coerce_value(key, value, default) + return cls(**values)._normalized() + + def _normalized(self) -> _VoxCPM2RuntimeConfig: + cfg = self + if cfg.enable_batched_cfm and cfg.enable_cfm_cuda_graph: + logger.warning( + "VoxCPM2 batched CFM and CFM CUDA Graph are mutually exclusive; " + "disabling CFM CUDA Graph and keeping batched CFM." + ) + cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False) + if cfg.enable_cfm_prealloc_output and not cfg.enable_cfm_cuda_graph: + logger.warning("VoxCPM2 CFM preallocated output requires CFM CUDA Graph; disabling it.") + cfg = dataclasses.replace(cfg, enable_cfm_prealloc_output=False) + if cfg.delayed_audio_copy_use_events and not cfg.enable_delayed_audio_copy: + logger.warning("VoxCPM2 delayed audio copy events require delayed audio copy; disabling event polling.") + cfg = dataclasses.replace(cfg, delayed_audio_copy_use_events=False) + if cfg.enable_batched_vae_decode and ( + cfg.enable_delayed_audio_copy or cfg.audio_emit_every > 1 or cfg.enable_vae_cuda_graph + ): + logger.warning( + "VoxCPM2 batched VAE decode is incompatible with delayed audio copy, " + "audio_emit_every > 1, and VAE CUDA Graph; disabling batched VAE decode." + ) + cfg = dataclasses.replace(cfg, enable_batched_vae_decode=False) + if cfg.enable_unified_decode_graph: + if cfg.enable_cfm_cuda_graph: + logger.info("VoxCPM2 unified decode graph supersedes independent CFM CUDA Graph; disabling it.") + cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False, enable_cfm_prealloc_output=False) + if not cfg.enable_batched_cfm: + logger.info("VoxCPM2 unified decode graph requires batched CFM; enabling it.") + cfg = dataclasses.replace(cfg, enable_batched_cfm=True) + return cfg + + def unified_decode_graph_available(self, *, use_cuda_graph: bool) -> bool: + return bool(use_cuda_graph and self.enable_unified_decode_graph and not self.deterministic_cfm_noise) + + @staticmethod + def _coerce_value(key: str, value: Any, default: Any) -> Any: + if isinstance(default, bool): + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + if isinstance(default, int) and not isinstance(default, bool): + value = int(value) + if key in {"audio_emit_every", "vae_decode_every", "batched_fsq_fusion_max_batch"}: + return max(1, value) + return value + if isinstance(default, float): + if key == "cfg_cutoff_ratio": + return min(1.0, max(0.0, float(value))) + return float(value) + if isinstance(default, str): + return str(value) + return value diff --git a/vllm_omni/model_executor/models/voxcpm2/scheduler.py b/vllm_omni/model_executor/models/voxcpm2/scheduler.py index 8aade3e74..a780c6955 100644 --- a/vllm_omni/model_executor/models/voxcpm2/scheduler.py +++ b/vllm_omni/model_executor/models/voxcpm2/scheduler.py @@ -6,6 +6,9 @@ from __future__ import annotations from vllm.v1.request import RequestStatus from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler +from vllm_omni.platforms import current_omni_platform + +from .runtime_config import _VoxCPM2RuntimeConfig class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler): @@ -19,11 +22,8 @@ class VoxCPM2OmniARAsyncScheduler(OmniARAsyncScheduler): """ def _unified_decode_graph_enabled(self) -> bool: - hf_config = getattr(self.vllm_config.model_config, "hf_config", None) - runtime_config = getattr(hf_config, "voxcpm2_runtime_config", None) - if isinstance(runtime_config, dict): - return bool(runtime_config.get("enable_unified_decode_graph", False)) - return bool(getattr(runtime_config, "enable_unified_decode_graph", False)) + runtime_config = _VoxCPM2RuntimeConfig.from_vllm_config(self.vllm_config) + return runtime_config.unified_decode_graph_available(use_cuda_graph=current_omni_platform.is_cuda()) def _should_defer_waiting_for_unified_decode_graph(self) -> bool: if not self._unified_decode_graph_enabled(): diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py index 555fdf6a6..94ac454d8 100644 --- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py +++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py @@ -46,6 +46,7 @@ from vllm_omni.utils.speaker_cache import ( from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest from .minicpm4_paged import MiniCPM4PagedForVoxCPM2, MiniCPM4PagedResidualLM +from .runtime_config import _VoxCPM2RuntimeConfig from .voxcpm2_import_utils import import_voxcpm2_core logger = init_logger(__name__) @@ -104,115 +105,6 @@ class _DecodeResidualMeta(NamedTuple): new_lm_hidden: torch.Tensor -@dataclasses.dataclass(frozen=True) -class _VoxCPM2RuntimeConfig: - enable_profiling: bool = False - enable_nvtx_profile: bool = False - enable_loc_dit_layer_nvtx: bool = False - enable_loc_dit_fused_qkv: bool = True - enable_loc_dit_fused_mlp: bool = True - enable_loc_dit_zero_dt_cache: bool = True - enable_loc_dit_fast_rope: bool = False - enable_loc_dit_skip_qkv_contig: bool = True - enable_loc_dit_reduce_overhead_no_cg: bool = False - enable_loc_dit_fullgraph_no_cg: bool = False - cfg_cutoff_ratio: float = 1.0 - decode_graph_capture_policy: str = "all" - enable_vae_cuda_graph: bool = False - enable_cfm_cuda_graph: bool = False - enable_cfm_prealloc_output: bool = False - enable_batched_cfm: bool = True - deterministic_cfm_noise: bool = False - deterministic_cfm_seed: int = 20260601 - audio_emit_every: int = 1 - vae_decode_every: int = 3 - enable_delayed_audio_copy: bool = False - delayed_audio_copy_use_events: bool = False - coalesce_audio_d2h: bool = True - enable_batched_vae_decode: bool = True - enable_batched_fsq_fusion: bool = False - batched_fsq_fusion_max_batch: int = 32 - enable_batched_prefill_tail: bool = False - enable_unified_decode_graph: bool = True - unified_decode_graph_max_batch_size: int = 64 - - @classmethod - def from_vllm_config(cls, vllm_config: VllmConfig) -> _VoxCPM2RuntimeConfig: - model_config = vllm_config.model_config - raw = getattr(model_config, "voxcpm2_runtime_config", None) - if raw is None: - raw = getattr(getattr(model_config, "hf_config", None), "voxcpm2_runtime_config", None) - if raw is None: - return cls() - if hasattr(raw, "to_dict"): - raw = raw.to_dict() - elif not isinstance(raw, dict) and hasattr(raw, "__dict__"): - raw = vars(raw) - if not isinstance(raw, dict): - logger.warning("Ignoring invalid voxcpm2_runtime_config=%r; expected a dict.", raw) - return cls() - - fields = {field.name: field for field in dataclasses.fields(cls)} - values: dict[str, Any] = {} - for key, value in raw.items(): - if key not in fields: - logger.warning("Ignoring unknown VoxCPM2 runtime config key: %s", key) - continue - default = getattr(cls(), key) - values[key] = cls._coerce_value(key, value, default) - return cls(**values)._normalized() - - def _normalized(self) -> _VoxCPM2RuntimeConfig: - cfg = self - if cfg.enable_batched_cfm and cfg.enable_cfm_cuda_graph: - logger.warning( - "VoxCPM2 batched CFM and CFM CUDA Graph are mutually exclusive; " - "disabling CFM CUDA Graph and keeping batched CFM." - ) - cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False) - if cfg.enable_cfm_prealloc_output and not cfg.enable_cfm_cuda_graph: - logger.warning("VoxCPM2 CFM preallocated output requires CFM CUDA Graph; disabling it.") - cfg = dataclasses.replace(cfg, enable_cfm_prealloc_output=False) - if cfg.delayed_audio_copy_use_events and not cfg.enable_delayed_audio_copy: - logger.warning("VoxCPM2 delayed audio copy events require delayed audio copy; disabling event polling.") - cfg = dataclasses.replace(cfg, delayed_audio_copy_use_events=False) - if cfg.enable_batched_vae_decode and ( - cfg.enable_delayed_audio_copy or cfg.audio_emit_every > 1 or cfg.enable_vae_cuda_graph - ): - logger.warning( - "VoxCPM2 batched VAE decode is incompatible with delayed audio copy, " - "audio_emit_every > 1, and VAE CUDA Graph; disabling batched VAE decode." - ) - cfg = dataclasses.replace(cfg, enable_batched_vae_decode=False) - if cfg.enable_unified_decode_graph: - if cfg.enable_cfm_cuda_graph: - logger.info("VoxCPM2 unified decode graph supersedes independent CFM CUDA Graph; disabling it.") - cfg = dataclasses.replace(cfg, enable_cfm_cuda_graph=False, enable_cfm_prealloc_output=False) - if not cfg.enable_batched_cfm: - logger.info("VoxCPM2 unified decode graph requires batched CFM; enabling it.") - cfg = dataclasses.replace(cfg, enable_batched_cfm=True) - return cfg - - @staticmethod - def _coerce_value(key: str, value: Any, default: Any) -> Any: - if isinstance(default, bool): - if isinstance(value, str): - return value.strip().lower() in ("1", "true", "yes", "on") - return bool(value) - if isinstance(default, int) and not isinstance(default, bool): - value = int(value) - if key in {"audio_emit_every", "vae_decode_every", "batched_fsq_fusion_max_batch"}: - return max(1, value) - return value - if isinstance(default, float): - if key == "cfg_cutoff_ratio": - return min(1.0, max(0.0, float(value))) - return float(value) - if isinstance(default, str): - return str(value) - return value - - def is_cjk_char(c: str) -> bool: """Check if a character is a CJK ideograph.""" cp = ord(c) @@ -1028,8 +920,8 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): self._cuda_graph_warmup_steps = 0 self._cuda_graph_warmup_threshold = 3 self._unified_graphs: dict[int, _CapturedUnifiedDecodeGraph] = {} - self._enable_unified_decode_graph = ( - use_cuda_graph and self._runtime_config.enable_unified_decode_graph and not self._deterministic_cfm_noise + self._enable_unified_decode_graph = self._runtime_config.unified_decode_graph_available( + use_cuda_graph=use_cuda_graph ) self._unified_decode_graph_max_batch_size = self._runtime_config.unified_decode_graph_max_batch_size self._unified_graph_bucket_sizes: frozenset[int] = self._build_unified_graph_bucket_sizes( From 86bdcaf3d5988c17d9fb6ead0034e31424d1aa67 Mon Sep 17 00:00:00 2001 From: Zhou Taichang Date: Sun, 5 Jul 2026 07:17:58 +0800 Subject: [PATCH 026/101] [BDE] Phase 1: engine-level KV cache management for DreamZero (RFC #4366) (#4534) --- .gitignore | 1 + tests/ar_diffusion/__init__.py | 1 + tests/ar_diffusion/test_engine_routing.py | 136 +++++ tests/ar_diffusion/test_kv_cache.py | 262 +++++++++ tests/ar_diffusion/test_kv_orchestration.py | 93 +++ tests/ar_diffusion/test_paged_attention.py | 394 +++++++++++++ tests/ar_diffusion/test_pipeline_bridge.py | 222 +++++++ tests/ar_diffusion/test_slot_mapping.py | 76 +++ tests/dreamzero/test_pipeline_state.py | 16 +- vllm_omni/deploy/dreamzero.yaml | 2 + vllm_omni/diffusion/data.py | 17 +- vllm_omni/diffusion/diffusion_engine.py | 58 +- .../models/dreamzero/causal_wan_model.py | 103 +++- .../models/dreamzero/pipeline_dreamzero.py | 436 +++++++++++--- .../models/dreamzero/state_dreamzero.py | 71 +-- .../models/dreamzero/video_export_worker.py | 29 + .../diffusion/worker/diffusion_worker.py | 28 +- vllm_omni/experimental/__init__.py | 9 +- .../experimental/ar_diffusion/__init__.py | 12 + vllm_omni/experimental/ar_diffusion/engine.py | 41 ++ .../ar_diffusion/kv_cache/__init__.py | 57 ++ .../ar_diffusion/kv_cache/config.py | 41 ++ .../ar_diffusion/kv_cache/manager.py | 497 ++++++++++++++++ .../ar_diffusion/kv_cache/paged.py | 223 +++++++ .../ar_diffusion/kv_cache/paged_attention.py | 550 ++++++++++++++++++ .../ar_diffusion/kv_cache/state.py | 183 ++++++ vllm_omni/experimental/ar_diffusion/runner.py | 409 +++++++++++++ 27 files changed, 3795 insertions(+), 172 deletions(-) create mode 100644 tests/ar_diffusion/__init__.py create mode 100644 tests/ar_diffusion/test_engine_routing.py create mode 100644 tests/ar_diffusion/test_kv_cache.py create mode 100644 tests/ar_diffusion/test_kv_orchestration.py create mode 100644 tests/ar_diffusion/test_paged_attention.py create mode 100644 tests/ar_diffusion/test_pipeline_bridge.py create mode 100644 tests/ar_diffusion/test_slot_mapping.py create mode 100644 vllm_omni/experimental/ar_diffusion/__init__.py create mode 100644 vllm_omni/experimental/ar_diffusion/engine.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/__init__.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/config.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/manager.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/paged.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/paged_attention.py create mode 100644 vllm_omni/experimental/ar_diffusion/kv_cache/state.py create mode 100644 vllm_omni/experimental/ar_diffusion/runner.py diff --git a/.gitignore b/.gitignore index a175e19e3..d9e8bd68b 100644 --- a/.gitignore +++ b/.gitignore @@ -208,6 +208,7 @@ cache/ .cache/ diffusion_cache/ kv_cache/ +!vllm_omni/experimental/ar_diffusion/kv_cache/ # Logs logs/ diff --git a/tests/ar_diffusion/__init__.py b/tests/ar_diffusion/__init__.py new file mode 100644 index 000000000..988131360 --- /dev/null +++ b/tests/ar_diffusion/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/ar_diffusion/test_engine_routing.py b/tests/ar_diffusion/test_engine_routing.py new file mode 100644 index 000000000..fc59e54ac --- /dev/null +++ b/tests/ar_diffusion/test_engine_routing.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the generic engine-backend dispatcher + AR-Diffusion runner wiring. + +``DiffusionEngine.resolve_engine_class`` is a generic dispatcher (``"default"`` / a +``DiffusionEngine`` subclass / an import-path string). DreamZero selects the +AR-Diffusion engine via its deploy config's ``engine_backend`` qualname — no +DreamZero/ar_diffusion knowledge lives in the public base, so the routing check +here is simply that the ``ARDiffusionEngine`` qualname resolves correctly. +""" + +from dataclasses import fields +from types import SimpleNamespace + +import pytest +from vllm.utils.import_utils import resolve_obj_by_qualname + +from vllm_omni.diffusion.data import OmniDiffusionConfig +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine +from vllm_omni.experimental.ar_diffusion.engine import ( + AR_DIFFUSION_MODEL_RUNNER_CLS, + ARDiffusionEngine, +) + + +def _cfg(backend): + return SimpleNamespace(engine_backend=backend) + + +def test_default_resolves_to_diffusion_engine(): + assert DiffusionEngine.resolve_engine_class(_cfg("default")) is DiffusionEngine + + +def test_subclass_type_is_returned(): + assert DiffusionEngine.resolve_engine_class(_cfg(ARDiffusionEngine)) is ARDiffusionEngine + + +def test_ar_diffusion_qualname_resolves(): + # How DreamZero's deploy config selects the engine: a full import-path string. + cls = DiffusionEngine.resolve_engine_class(_cfg("vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine")) + assert cls is ARDiffusionEngine + + +def test_non_engine_type_raises(): + with pytest.raises(TypeError): + DiffusionEngine.resolve_engine_class(_cfg(dict)) + + +def test_qualname_not_an_engine_raises(): + with pytest.raises(TypeError): + DiffusionEngine.resolve_engine_class(_cfg("builtins.dict")) + + +def test_bad_qualname_raises(): + with pytest.raises(ValueError): + DiffusionEngine.resolve_engine_class(_cfg("not.a.real.module.NoSuchEngine")) + + +def test_ar_diffusion_engine_is_diffusion_engine_subclass(): + assert issubclass(ARDiffusionEngine, DiffusionEngine) + + +def test_config_engine_backend_field_defaults_to_default(): + field = {f.name: f for f in fields(OmniDiffusionConfig)}["engine_backend"] + assert field.default == "default" + + +# --- AR-Diffusion worker/runner wiring ----------------------------------------------- + + +def test_ar_diffusion_model_runner_cls_resolves(): + cls = resolve_obj_by_qualname(AR_DIFFUSION_MODEL_RUNNER_CLS) + assert cls.__name__ == "ARDiffusionModelRunner" + + +def test_engine_class_declares_its_runner(): + """Routing lives on the engine class (review: hsliuustc0106) — engines + never mutate od_config; the worker resolves the runner off the class.""" + assert ARDiffusionEngine.default_diffusion_model_runner_cls == AR_DIFFUSION_MODEL_RUNNER_CLS + assert DiffusionEngine.default_diffusion_model_runner_cls is None + + +def _select_runner(od_config, platform_default="PLATFORM"): + """Mirror of the worker's runner-selection chain (override > engine > platform).""" + runner_override = getattr(od_config, "diffusion_model_runner_cls", None) + if isinstance(runner_override, str) and runner_override: + return runner_override + engine_cls = DiffusionEngine.resolve_engine_class(od_config) + engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None) + if isinstance(engine_runner, str) and engine_runner: + return engine_runner + return platform_default + + +def test_runner_selection_chain(): + ar = "vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine" + # engine-declared runner wins over platform default + assert ( + _select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend=ar)) + == AR_DIFFUSION_MODEL_RUNNER_CLS + ) + # explicit override wins over the engine's declaration + assert ( + _select_runner(SimpleNamespace(diffusion_model_runner_cls="my.custom.Runner", engine_backend=ar)) + == "my.custom.Runner" + ) + # default engine -> platform default + assert _select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend="default")) == "PLATFORM" + + +def test_dreamzero_pipeline_rejects_non_ar_diffusion_engine(): + """Review (hsliuustc0106): a stale config with engine_backend='default' + must fail at init, not mid-forward on the first KV access.""" + from types import SimpleNamespace + + import pytest + + from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline + + with pytest.raises(ValueError, match="requires the AR-Diffusion engine"): + DreamZeroPipeline(od_config=SimpleNamespace(engine_backend="default")) + with pytest.raises(ValueError, match="requires the AR-Diffusion engine"): + DreamZeroPipeline(od_config=SimpleNamespace()) + + +def test_runner_rejects_multi_sequence_config(): + """Review (hsliuustc0106): the paged path writes batch index 0 only, so + max_num_seqs > 1 must be rejected at init.""" + from types import SimpleNamespace + + import pytest + + from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner + + fake = SimpleNamespace(od_config=SimpleNamespace(max_num_seqs=4)) + with pytest.raises(ValueError, match="max_num_seqs=1"): + ARDiffusionModelRunner._preallocate_kv_cache(fake) diff --git a/tests/ar_diffusion/test_kv_cache.py b/tests/ar_diffusion/test_kv_cache.py new file mode 100644 index 000000000..cbb9656e5 --- /dev/null +++ b/tests/ar_diffusion/test_kv_cache.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2). + +Covers the request adapter, the chunk-window spec/manager (registration + the +eviction policy), and the pool builder — exercised against the installed vLLM +KV stack on CPU (block bookkeeping only, no GPU tensors). +""" + +import pytest +import torch +from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind +from vllm.v1.request import RequestStatus + +try: + from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry +except ModuleNotFoundError: + KVCacheSpecRegistry = None + from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map + +from vllm_omni.experimental.ar_diffusion.kv_cache import ( + ARDiffusionKVCache, + ARDiffusionKVConfig, + ARDiffusionRequestAdapter, + ChunkWindowManager, + ChunkWindowSpec, + allocate_kv_pool_with_views, + build_kv_manager, + compute_num_blocks, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens + +BLOCK = 16 + + +def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False): + return ChunkWindowSpec( + block_size=BLOCK, + num_kv_heads=4, + head_size=64, + dtype=torch.float16, + sliding_window=window_chunks * chunk_size, + chunk_size=chunk_size, + window_chunks=window_chunks, + sink_chunks=sink_chunks, + reset_at_boundary=reset_at_boundary, + ) + + +# --- ChunkWindowSpec registration ------------------------------------------- + + +def test_spec_registration_resolves_to_chunk_window_manager(): + # Without explicit registration the MRO walk would fall back to the parent + # SlidingWindowManager; assert the subclass manager wins. + spec = make_spec() + if KVCacheSpecRegistry is not None: + assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager + else: + assert spec_manager_map[type(spec)] is ChunkWindowManager + + +def test_spec_kind_is_sliding_window(): + assert get_kv_cache_spec_kind(make_spec()) == KVCacheSpecKind.SLIDING_WINDOW + + +def test_spec_rejects_inconsistent_window(): + with pytest.raises(ValueError): + ChunkWindowSpec( + block_size=BLOCK, + num_kv_heads=4, + head_size=64, + dtype=torch.float16, + sliding_window=99, # != window_chunks * chunk_size + chunk_size=BLOCK, + window_chunks=2, + ) + + +# --- eviction policy (pure) ------------------------------------------------- + + +def test_sliding_replace_keeps_window(): + # window = 2 chunks * 16 = 32. Base sliding formula keeps `window` tokens; + # the snap is to chunk boundaries. + def skip(n): + return chunk_window_skipped_tokens(n, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False) + + assert skip(32) == 0 # nothing past the window yet + assert skip(48) == 16 # one chunk fell out of the window + assert skip(64) == 32 + + +def test_sliding_replace_snaps_to_chunk_boundary(): + # A non-chunk-aligned overflow must snap down so a chunk is never half-evicted. + skip = chunk_window_skipped_tokens(50, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False) + assert skip % 16 == 0 and skip == 16 + + +def test_sink_chunks_protected(): + # sink = 1 chunk (16 tokens) is never skipped. + skip = chunk_window_skipped_tokens(80, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=False) + # raw overflow = 80 - 32 - 16 = 32 -> snapped 32 + assert skip == 32 + + +def test_reset_at_boundary_drops_completed_past_sink(): + skip = chunk_window_skipped_tokens(48, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=True) + # completed = 48; sink = 16 -> drop 32 + assert skip == 32 + + +# --- ARDiffusionKVConfig ------------------------------------------------------------ + + +def test_kv_config_sliding_window_property(): + assert ARDiffusionKVConfig(chunk_size=16, window_chunks=3).sliding_window == 48 + assert ARDiffusionKVConfig(chunk_size=16, window_chunks=None).sliding_window is None + + +# --- ARDiffusionRequestAdapter ------------------------------------------------------ + + +def test_adapter_advances_per_chunk_not_per_step(): + a = ARDiffusionRequestAdapter("r0", chunk_size=16) + assert a.num_computed_tokens == 0 + assert a.num_tokens == 16 # in-flight chunk + a.on_chunk_committed() + assert a.num_computed_tokens == 16 + assert a.num_tokens == 32 + + +def test_adapter_accounts_for_prefill_prefix(): + a = ARDiffusionRequestAdapter("r0", chunk_size=16, prefill_prefix_tokens=4) + assert a.num_computed_tokens == 4 + assert a.num_prompt_tokens == 4 + assert a.num_tokens == 20 + + +def test_adapter_status_is_vllm_enum(): + assert isinstance(ARDiffusionRequestAdapter("r0", chunk_size=16).status, RequestStatus) + + +# --- pool / manager --------------------------------------------------------- + + +def test_compute_num_blocks(): + # 1 MiB budget at fraction 0.5 with 16 KiB pages -> 32 blocks. + assert compute_num_blocks(1 << 20, 0.5, 16 << 10) == 32 + + +def test_paged_pool_layout_exposes_flat_slot_views(): + kv_pools, k_pools, v_pools = allocate_kv_pool_with_views( + num_blocks=4, + block_size=BLOCK, + num_layers=1, + num_kv_heads=4, + head_dim=64, + dtype=torch.float32, + device=torch.device("cpu"), + ) + assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64) + assert k_pools[0].shape == (4 * BLOCK, 4, 64) + assert v_pools[0].shape == (4 * BLOCK, 4, 64) + + k_pools[0][BLOCK + 3].fill_(7) + v_pools[0][2 * BLOCK + 5].fill_(11) + assert torch.equal(kv_pools[0][0, 1, 3], k_pools[0][BLOCK + 3]) + assert torch.equal(kv_pools[0][1, 2, 5], v_pools[0][2 * BLOCK + 5]) + + +def test_build_manager_allocate_free_roundtrip(): + """End-to-end: a ARDiffusionRequestAdapter drives a real KVCacheManager. + + This is the adapter conformance check — if the adapter were missing an + attribute the manager reads, allocate_slots/free would raise here. + """ + spec = make_spec() + mgr = build_kv_manager(spec, ["layer0"], num_blocks=16, max_model_len=1024) + free_before = mgr.block_pool.get_num_free_blocks() + + adapter = ARDiffusionRequestAdapter("req-0", chunk_size=BLOCK) + blocks = mgr.allocate_slots(adapter, num_new_tokens=BLOCK, full_sequence_must_fit=True) + assert blocks is not None + assert mgr.block_pool.get_num_free_blocks() < free_before + + mgr.free(adapter) + assert mgr.block_pool.get_num_free_blocks() == free_before + + +def test_cross_attn_pool_deducted_from_self_attn_budget(): + """The cross-attn pool is allocated directly; its bytes are subtracted from + the self-attn paged-pool budget so the two together stay within the free + memory budget (review: zwhzzz0821).""" + L = 512 + avail = 1 << 30 # 1 GiB + kv = ARDiffusionKVCache( + ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2, gpu_memory_fraction=0.5), + num_layers=2, + num_kv_heads=4, + head_size=64, + dtype=torch.float16, + block_size=BLOCK, + max_model_len=4096, + available_bytes=avail, + cross_attn_length=L, + device=torch.device("cpu"), + ) + cross_bytes = 2 * 2 * L * 4 * 64 * torch.float16.itemsize * 2 # K+V, pos+neg, layers + expected = compute_num_blocks(avail - cross_bytes, 0.5, kv.spec.page_size_bytes * 2) + assert kv.num_blocks == expected + assert expected > 2 * (2 + 1) + 2 # above the min-blocks floor, so the deduction is what's tested + + +def _make_kv(*, local_branches, num_frame_per_block=2, window_chunks=9): + return ARDiffusionKVCache( + ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks), + num_layers=1, + num_kv_heads=4, + head_size=64, + dtype=torch.float32, + block_size=BLOCK, + max_model_len=4096, + available_bytes=1 << 16, # tiny -> the floor binds + device=torch.device("cpu"), + local_branches=local_branches, + num_frame_per_block=num_frame_per_block, + ) + + +def test_pool_floor_is_branch_aware(): + """CFG-parallel rank (one local branch) sizes for one window + in-flight chunk; + a single-process run (both branches) sizes for two. Scratch scales the same way.""" + one = _make_kv(local_branches=1) + two = _make_kv(local_branches=2) + + assert one.managed_num_blocks == 1 * (9 + 2) + 2 # 13 + assert two.managed_num_blocks == 2 * (9 + 2) + 2 # 24 + assert one.scratch_num_blocks == one.scratch_blocks_per_branch + assert two.scratch_num_blocks == 2 * two.scratch_blocks_per_branch + assert one.num_blocks_total == 13 + one.scratch_blocks_per_branch + + +def test_scratch_maps_to_slot_zero_with_one_local_branch(): + """A CFG-parallel rank runs exactly one branch: whichever CFG side it is, + its scratch lands in the rank's single slot (no dead second slot).""" + one = _make_kv(local_branches=1) + assert one.scratch_block_ids(True, 0, 2) == one.scratch_block_ids(False, 0, 2) + + two = _make_kv(local_branches=2) + assert two.scratch_block_ids(True, 0, 2) != two.scratch_block_ids(False, 0, 2) + + +def test_scratch_exhaustion_still_raises(): + one = _make_kv(local_branches=1) + cap = one.scratch_blocks_per_branch + with pytest.raises(RuntimeError, match="scratch blocks exhausted"): + one.scratch_block_ids(False, 0, cap + 1) + + +def test_invalid_local_branches_rejected(): + with pytest.raises(ValueError, match="local_branches"): + _make_kv(local_branches=3) diff --git a/tests/ar_diffusion/test_kv_orchestration.py b/tests/ar_diffusion/test_kv_orchestration.py new file mode 100644 index 000000000..4ba5cb49b --- /dev/null +++ b/tests/ar_diffusion/test_kv_orchestration.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for ARDiffusionKVCache — the engine-level KV orchestration body (Phase 1).""" + +import pytest +import torch + +from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig + +DIMS = dict(num_layers=2, num_kv_heads=4, head_size=64, dtype=torch.float16, block_size=16) + + +def make_cache(*, chunk_size=16, window_chunks=2, available_bytes=1 << 24): + cfg = ARDiffusionKVConfig(enable=True, chunk_size=chunk_size, window_chunks=window_chunks) + return ARDiffusionKVCache(cfg, max_model_len=4096, available_bytes=available_bytes, **DIMS) + + +def test_requires_enabled_config(): + with pytest.raises(ValueError): + ARDiffusionKVCache(ARDiffusionKVConfig(enable=False), max_model_len=256, available_bytes=1 << 20, **DIMS) + + +def test_requires_bounded_window(): + cfg = ARDiffusionKVConfig(enable=True, chunk_size=16, window_chunks=None) + with pytest.raises(ValueError): + ARDiffusionKVCache(cfg, max_model_len=256, available_bytes=1 << 20, **DIMS) + + +def test_full_request_lifecycle_and_eviction(): + """begin -> per-chunk allocate/slots/commit over a long rollout -> free. + + Exercises the orchestrator end-to-end and asserts the chunk window bounds + memory (pool plateaus) and frees cleanly. + """ + kv = make_cache(chunk_size=16, window_chunks=2) + free_total = kv.manager.block_pool.get_num_free_blocks() + + adapter = kv.begin_request("req-0") + free_after = [] + for k in range(10): + block_table = kv.allocate_chunk(adapter) + slots = kv.chunk_write_slots(adapter) + # Slots target real blocks in this chunk's table; length == chunk_size. + assert len(slots) == kv.spec.chunk_size + used = {int(s) // kv.block_size for s in slots} + assert kv.null_block_id not in used + assert used <= set(block_table) + # Resident window stays bounded by window + the in-flight chunk. + assert len(kv.window_block_ids(adapter)) <= kv.spec.window_chunks + 1 + free_after.append(kv.manager.block_pool.get_num_free_blocks()) + kv.commit_chunk(adapter) + + # Pool memory plateaus once the window is full (eviction recycles blocks). + assert free_after[-1] == free_after[kv.spec.window_chunks] + + kv.end_request(adapter) + assert kv.manager.block_pool.get_num_free_blocks() == free_total + + +def test_num_computed_advances_per_chunk(): + kv = make_cache(chunk_size=16, window_chunks=2) + a = kv.begin_request("r") + assert a.num_computed_tokens == 0 + kv.allocate_chunk(a) + kv.commit_chunk(a) + assert a.num_computed_tokens == 16 + kv.end_request(a) + + +def test_state_close_frees_both_branch_blocks(): + """ARDiffusionKVState.close() returns both CFG branches' pool blocks to the pool. + + This is the primitive the runner's LRU eviction relies on: when a session is + evicted, close() must free the blocks both adapters hold, or session churn + leaks pool ownership (review P1). + """ + from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + + kv = make_cache(chunk_size=16, window_chunks=2) + free_total = kv.manager.block_pool.get_num_free_blocks() + + pos = kv.begin_request("bde__s") + neg = kv.begin_request("bde__s__neg") + state = ARDiffusionKVState(kv, pos, neg, num_layers=kv.num_layers) + for _ in range(3): + for adapter in (pos, neg): + kv.allocate_chunk(adapter) + kv.commit_chunk(adapter) + # Both branches hold resident blocks now. + assert kv.manager.block_pool.get_num_free_blocks() < free_total + + state.close() + # All blocks returned — no leak across an evicted session. + assert kv.manager.block_pool.get_num_free_blocks() == free_total diff --git a/tests/ar_diffusion/test_paged_attention.py b/tests/ar_diffusion/test_paged_attention.py new file mode 100644 index 000000000..1836656f9 --- /dev/null +++ b/tests/ar_diffusion/test_paged_attention.py @@ -0,0 +1,394 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for AR-Diffusion paged self-attention contexts.""" + +from __future__ import annotations + +import subprocess +from importlib.util import find_spec +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import torch + +from vllm_omni.experimental.ar_diffusion.kv_cache import ( + ARDiffusionKVCache, + ARDiffusionKVConfig, + ARDiffusionPagedLayerContext, + ARDiffusionPagedLayerInputs, + ar_diffusion_paged_attention, + paged_write_attn, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + +BLOCK = 16 +N_HEADS = 4 +HEAD_DIM = 64 + + +def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu")): + cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks) + kv = ARDiffusionKVCache( + cfg, + num_layers=num_layers, + num_kv_heads=N_HEADS, + head_size=HEAD_DIM, + dtype=dtype, + block_size=BLOCK, + max_model_len=4096, + available_bytes=1 << 26, + device=device, + ) + pos = kv.begin_request("r-pos") + neg = kv.begin_request("r-neg") + return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers) + + +def _dense_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor: + scores = torch.einsum("bqhd,bkhd->bhqk", query.float(), key.float()) * (HEAD_DIM**-0.5) + probs = torch.softmax(scores, dim=-1).to(value.dtype) + return torch.einsum("bhqk,bkhd->bqhd", probs, value) + + +def _cuda_flash_attn_usable() -> bool: + if not torch.cuda.is_available(): + return False + try: + spec = find_spec("vllm.vllm_flash_attn") + if spec is None or spec.origin is None: + return True + fa2_so = Path(spec.origin).parent / "_vllm_fa2_C.abi3.so" + linked = subprocess.check_output(["ldd", str(fa2_so)], text=True, timeout=5) + except Exception: + return True + if "libcudart.so.13" not in linked: + return True + try: + out = subprocess.check_output( + ["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"], + text=True, + timeout=5, + ) + driver_major = int(out.splitlines()[0].split(".")[0]) + except Exception: + return True + return driver_major >= 580 + + +def _commit_video_span( + kv: ARDiffusionKVCache, + st: ARDiffusionKVState, + *, + is_negative: bool, + n_chunks: int, + dtype: torch.dtype, + device: torch.device, +) -> tuple[torch.Tensor, torch.Tensor]: + ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(device) + k = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + v = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + kv._k_pools[0][ctx.current_video_slot_mapping] = k[0] + kv._v_pools[0][ctx.current_video_slot_mapping] = v[0] + st.commit_paged_context(is_negative) + return k, v + + +def test_paged_context_allocates_lazily_and_commits_after_forward(): + _, st = make_state() + + contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True) + ctx = contexts[0].forward_ctx + assert isinstance(contexts[0], ARDiffusionPagedLayerContext) + assert st.pos.completed_chunks == 0 + assert ctx.current_video_slot_mapping is None + + ctx.ensure_video_slots(torch.device("cpu")) + assert st.pos.completed_chunks == 0 + assert len(ctx.current_video_block_ids) == 1 + + st.commit_paged_context(False) + assert st.pos.completed_chunks == 1 + assert st._committed[False] == BLOCK + + +def test_scratch_video_and_action_blocks_do_not_commit(): + kv, st = make_state() + + ctx = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=False)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + ctx.ensure_action_slots(3, torch.device("cpu")) + + assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 2) + assert ctx.action_scratch_block_ids == kv.scratch_block_ids(False, 2, 1) + st.commit_paged_context(False) + assert st.pos.completed_chunks == 0 + assert st._committed[False] == 0 + + +def test_pipeline_kv_get_paged_path_has_no_gather_backend(): + kv, st = make_state() + assert not hasattr(kv, "gather_window_all_layers") + + from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline + + pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline) + pipeline._ar_diffusion_kv_state = st + contexts = pipeline._kv_get(MagicMock(), False, seq_len=BLOCK, update_kv_cache=False) + + assert len(contexts) == 1 + assert isinstance(contexts[0], ARDiffusionPagedLayerContext) + + +@pytest.mark.parametrize("history_chunks", [0, 1, 3]) +@pytest.mark.parametrize("action_len", [0, 3]) +@pytest.mark.parametrize("commit_current", [False, True]) +def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, commit_current): + torch.manual_seed(0) + device = torch.device("cpu") + dtype = torch.float32 + kv, st = make_state(dtype=dtype, device=device, window_chunks=2) + + history_k_parts: list[torch.Tensor] = [] + history_v_parts: list[torch.Tensor] = [] + if history_chunks: + k, v = _commit_video_span( + kv, + st, + is_negative=False, + n_chunks=history_chunks, + dtype=dtype, + device=device, + ) + history_k_parts.append(k) + history_v_parts.append(v) + + ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[0].forward_ctx + ctx.ensure_video_slots(device) + current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0] + kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0] + + action_k = action_v = None + if action_len: + ctx.ensure_action_slots(action_len, device) + action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + kv._k_pools[0][ctx.action_slot_mapping] = action_k[0] + kv._v_pools[0][ctx.action_slot_mapping] = action_v[0] + + query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table( + action_len=action_len, + query_len=query.shape[1], + device=device, + ) + paged = ar_diffusion_paged_attention( + query, + kv.key_cache(0), + kv.value_cache(0), + block_table=block_table, + query_start_loc=query_start_loc, + seq_lens=seq_lens, + max_query_len=max_query_len, + max_seq_len=max_seq_len, + softmax_scale=HEAD_DIM**-0.5, + causal=False, + ) + + if history_k_parts: + history_k = torch.cat(history_k_parts, dim=1) + history_v = torch.cat(history_v_parts, dim=1) + else: + history_k = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + history_v = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :] + new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :] + if action_len: + new_k = torch.cat([new_k, action_k], dim=1) + new_v = torch.cat([new_v, action_v], dim=1) + ref = _dense_attention(query, new_k, new_v) + + torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5) + + before = st.pos.completed_chunks + st.commit_paged_context(False) + assert st.pos.completed_chunks == before + (1 if commit_current else 0) + + +@pytest.mark.skipif(not _cuda_flash_attn_usable(), reason="usable CUDA FlashAttention is required") +@pytest.mark.parametrize("history_chunks", [1, 3]) +@pytest.mark.parametrize("action_len", [0, 3]) +@pytest.mark.parametrize("commit_current", [False, True]) +def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len, commit_current): + pytest.importorskip("vllm.vllm_flash_attn") + torch.manual_seed(0) + device = torch.device("cuda") + dtype = torch.float16 + kv, st = make_state(dtype=dtype, device=device, window_chunks=2) + + history_k, history_v = _commit_video_span( + kv, + st, + is_negative=False, + n_chunks=history_chunks, + dtype=dtype, + device=device, + ) + + layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit_current)[0] + ctx = layer_ctx.forward_ctx + current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + action_k = action_v = None + if action_len: + action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + + query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device) + + # The production path: once-per-forward host prep, then the fused + # write+attend custom op consuming the NamedTuple payload. + ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1]) + inputs = layer_ctx.to_layer_inputs() + assert isinstance(inputs, ARDiffusionPagedLayerInputs) + paged = paged_write_attn( + inputs, + query[0], + current_k[0], + current_v[0], + action_k[0] if action_len else None, + action_v[0] if action_len else None, + HEAD_DIM**-0.5, + ).unsqueeze(0) + + # Direct python-fn call on the same (already written) pools must be + # bit-exact: identical kernel, identical inputs. + direct = ar_diffusion_paged_attention( + query, + kv.key_cache(0), + kv.value_cache(0), + block_table=ctx.block_table, + query_start_loc=ctx.query_start_loc, + seq_lens=ctx.seq_lens, + max_query_len=ctx.max_query_len, + max_seq_len=ctx.max_seq_len, + softmax_scale=HEAD_DIM**-0.5, + causal=False, + ) + assert torch.equal(paged, direct) + + new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :] + new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :] + if action_len: + new_k = torch.cat([new_k, action_k], dim=1) + new_v = torch.cat([new_v, action_v], dim=1) + ref = _dense_attention(query, new_k, new_v) + + torch.testing.assert_close(paged, ref, rtol=2e-2, atol=2e-2) + + +def test_block_table_padded_to_fixed_width(): + """Shapes must be constant across window growth: only values change.""" + device = torch.device("cpu") + kv, st = make_state(window_chunks=2) + + ctx1 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx1.prepare(device=device, action_len=0, query_len=BLOCK) + st.commit_paged_context(False) + + ctx2 = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx + ctx2.prepare(device=device, action_len=0, query_len=BLOCK) + st.commit_paged_context(False) + + # 1-block vs 2-block visible history: same table width, same max_seq_len. + assert ctx1.block_table.shape == ctx2.block_table.shape + assert ctx1.max_seq_len == ctx2.max_seq_len + expected_width = kv.spec.sliding_window // kv.block_size + 1 + assert ctx1.block_table.shape == (1, expected_width) + # Real lengths live in seq_lens, not the padded table. + assert int(ctx1.seq_lens[0]) == BLOCK + assert int(ctx2.seq_lens[0]) == 2 * BLOCK + + +def test_prepare_is_idempotent_and_layers_share_metadata(): + device = torch.device("cpu") + _, st = make_state(num_layers=2) + contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False) + fctx = contexts[0].forward_ctx + fctx.prepare(device=device, action_len=0, query_len=BLOCK) + table = fctx.block_table + fctx.prepare(device=device, action_len=0, query_len=BLOCK) + assert fctx.block_table is table # memoized, not rebuilt + + i0, i1 = contexts[0].to_layer_inputs(), contexts[1].to_layer_inputs() + # 0-dim tensors (NOT python ints) so dynamo doesn't install per-layer + # value guards on the shared block code object. + assert isinstance(i0.layer_idx, torch.Tensor) and int(i0.layer_idx) == 0 + assert isinstance(i1.layer_idx, torch.Tensor) and int(i1.layer_idx) == 1 + # All layers share the same metadata tensor objects. + assert i0.block_table is i1.block_table + assert i0.seq_lens is i1.seq_lens + assert i0.video_slots is i1.video_slots + + +def test_layer_inputs_before_prepare_raises(): + _, st = make_state() + layer_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0] + with pytest.raises(RuntimeError, match="before prepare"): + layer_ctx.to_layer_inputs() + + +def test_custom_op_registration_idempotent(): + import importlib + import sys + + assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn") + mod = "vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention" + saved = sys.modules.pop(mod) + try: + importlib.import_module(mod) # re-registration must not raise + finally: + sys.modules[mod] = saved + assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn") + + +def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(): + """The op must trace as one opaque node: fullgraph OK, and changed tensor + VALUES (new slots / block ids) must not trigger recompilation.""" + import torch._dynamo + + device = torch.device("cpu") + kv, st = make_state(num_layers=2, window_chunks=2) + + def run_one_forward(commit): + contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=commit) + fctx = contexts[0].forward_ctx + fctx.prepare(device=device, action_len=0, query_len=BLOCK) + q = torch.randn(BLOCK, N_HEADS, HEAD_DIM) + k = torch.randn(BLOCK, N_HEADS, HEAD_DIM) + v = torch.randn(BLOCK, N_HEADS, HEAD_DIM) + # Both layers through ONE compiled fn: layer_idx is a tensor, so a + # different layer must NOT recompile (all 40 DiT blocks share the + # block-forward code object in production). + for layer_ctx in contexts: + out = compiled(layer_ctx.to_layer_inputs(), q, k, v) + st.commit_paged_context(False) + return out + + torch._dynamo.reset() + from torch._dynamo.testing import CompileCounter + + counter = CompileCounter() + + def fn(inputs, q, k, v): + return paged_write_attn(inputs, q, k, v, None, None, HEAD_DIM**-0.5) * 1.0 + + compiled = torch.compile(fn, backend=counter, fullgraph=True) + + run_one_forward(commit=True) # history grows between calls -> + run_one_forward(commit=True) # block-table VALUES change, shapes don't + run_one_forward(commit=False) + + assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}" diff --git a/tests/ar_diffusion/test_pipeline_bridge.py b/tests/ar_diffusion/test_pipeline_bridge.py new file mode 100644 index 000000000..83c710efa --- /dev/null +++ b/tests/ar_diffusion/test_pipeline_bridge.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the ARDiffusionKVState paged-attention pipeline bridge.""" + +import pytest +import torch + +from vllm_omni.experimental.ar_diffusion.kv_cache import ( + ARDiffusionKVCache, + ARDiffusionKVConfig, + ARDiffusionPagedLayerContext, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + +BLOCK = 16 +N_HEADS = 4 +HEAD_DIM = 64 + + +def make_state(num_layers=1, window_chunks=4, cross_attn_length=0): + cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks) + kv = ARDiffusionKVCache( + cfg, + num_layers=num_layers, + num_kv_heads=N_HEADS, + head_size=HEAD_DIM, + dtype=torch.float32, + block_size=BLOCK, + max_model_len=4096, + available_bytes=1 << 24, + cross_attn_length=cross_attn_length, + device=torch.device("cpu"), + ) + pos = kv.begin_request("r-pos") + neg = kv.begin_request("r-neg") + return kv, ARDiffusionKVState(kv, pos, neg, num_layers=num_layers) + + +def _prepare_and_commit(st: ARDiffusionKVState, is_negative: bool, n_chunks: int) -> None: + ctx = st.get_kv_caches(is_negative, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + st.commit_paged_context(is_negative) + + +def test_get_returns_paged_layer_contexts_when_nothing_committed(): + _, st = make_state(num_layers=2) + for neg in (False, True): + contexts = st.get_kv_caches(neg, seq_len=BLOCK, commit_current=False) + assert len(contexts) == 2 + assert all(isinstance(ctx, ARDiffusionPagedLayerContext) for ctx in contexts) + assert contexts[0].history_block_ids == [] + + +def test_managed_paged_context_commits_only_after_forward(): + kv, st = make_state(num_layers=1) + contexts = st.get_kv_caches(False, seq_len=2 * BLOCK, commit_current=True) + ctx = contexts[0].forward_ctx + + assert st.pos.completed_chunks == 0 + assert kv.window_block_ids(st.pos) == [] + ctx.ensure_video_slots(torch.device("cpu")) + assert st.pos.completed_chunks == 0 + assert len(ctx.current_video_block_ids) == 2 + + st.commit_paged_context(False) + + assert st.pos.completed_chunks == 2 + assert st._committed[False] == 2 * BLOCK + assert len(kv.window_block_ids(st.pos)) == 2 + + +def test_scratch_paged_context_does_not_commit(): + kv, st = make_state(num_layers=1) + contexts = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False) + ctx = contexts[0].forward_ctx + ctx.ensure_video_slots(torch.device("cpu")) + + assert ctx.current_video_block_ids == kv.scratch_block_ids(False, 0, 1) + st.commit_paged_context(False) + + assert st.pos.completed_chunks == 0 + assert st._committed[False] == 0 + assert kv.window_block_ids(st.pos) == [] + + +def test_eviction_bounds_resident_window(): + kv, st = make_state(num_layers=1, window_chunks=3) + _prepare_and_commit(st, False, 3) + assert len(kv.window_block_ids(st.pos)) == 3 + + _prepare_and_commit(st, False, 2) + ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=False)[0].forward_ctx + visible_blocks, video_len = ctx.video_block_table(torch.device("cpu")) + assert len(visible_blocks) == 3 + assert video_len == 3 * BLOCK + + +def test_branches_are_independent(): + kv, st = make_state() + _prepare_and_commit(st, False, 1) + assert len(kv.window_block_ids(st.pos)) == 1 + assert kv.window_block_ids(st.neg) == [] + + +def test_reset_clears_session_window(): + kv, st = make_state(num_layers=1, window_chunks=4) + _prepare_and_commit(st, False, 2) + _prepare_and_commit(st, True, 1) + assert st._committed[False] == 2 * BLOCK and st._committed[True] == BLOCK + free_before = kv.manager.block_pool.get_num_free_blocks() + + st.reset() + + assert st._committed == {False: 0, True: 0} + assert st._paged_pending == {False: None, True: None} + assert kv.window_block_ids(st.pos) == [] + assert kv.window_block_ids(st.neg) == [] + assert kv.manager.block_pool.get_num_free_blocks() > free_before + _prepare_and_commit(st, False, 1) + assert len(kv.window_block_ids(st.pos)) == 1 + + +def test_get_cross_kv_caches_raises_before_populate(): + """Engine ownership guard: a cross-attn read before _kv_populate_cross must fail loud.""" + _, st = make_state() # cross_attn_length == 0, nothing populated + for neg in (False, True): + with pytest.raises(RuntimeError, match="cross-attn read before"): + st.get_cross_kv_caches(neg) + # Guards the full AND: the populated flag alone is not enough without a pool. + st._cross_text_populated[False] = True + with pytest.raises(RuntimeError, match="cross-attn read before"): + st.get_cross_kv_caches(False) + + +def test_get_cross_kv_caches_returns_pool_dicts_when_populated(): + L = 8 + kv, st = make_state(num_layers=2, cross_attn_length=L) + written = [] + for i in range(2): + k = torch.randn(1, L, N_HEADS, HEAD_DIM) + v = torch.randn(1, L, N_HEADS, HEAD_DIM) + kv.write_cross_kv(i, False, k, v) + written.append((k, v)) + st._cross_text_populated[False] = True + + out = st.get_cross_kv_caches(False) + assert len(out) == 2 + for i, (k, v) in enumerate(written): + assert out[i]["is_init"] is True + assert out[i]["k"].shape == (1, L, N_HEADS, HEAD_DIM) + assert torch.equal(out[i]["k"], k) + assert torch.equal(out[i]["v"], v) + + +def test_kv_create_is_noop_engine_owns_allocation(): + from unittest.mock import MagicMock + + from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline + + p = DreamZeroPipeline.__new__(DreamZeroPipeline) + p._ar_diffusion_kv_state = object() + state = MagicMock() + p._kv_create(state, 1, "float32", "cpu", 24, 4, 64) + state.create_kv_caches.assert_not_called() + + +def test_get_kv_cache_requires_frame_aligned_seqlen(): + _, st = make_state() + with pytest.raises(AssertionError, match="frame-aligned"): + st.get_kv_caches(False, seq_len=BLOCK + 1, commit_current=True) + + +def test_prepare_one_branch_allocates_nothing_for_the_other(): + """CFG-parallel laziness: a rank prepares only the branch it runs.""" + kv, st = make_state(num_layers=1, window_chunks=4) + pos_ctx = st.get_kv_caches(False, seq_len=BLOCK, commit_current=True)[0].forward_ctx + neg_ctx = st.get_kv_caches(True, seq_len=BLOCK, commit_current=True)[0].forward_ctx + + pos_ctx.prepare(device=torch.device("cpu"), action_len=0, query_len=BLOCK) + + assert pos_ctx._allocated_video + assert not neg_ctx._allocated_video + assert kv.window_block_ids(st.neg) == [] + + +def test_failed_forward_tears_down_session(monkeypatch): + """Review follow-up (hsliuustc0106): a forward that dies partway must not + leave allocated-but-uncommitted KV behind — the session is torn down (pool + blocks freed, model-local state dropped) and the error propagates.""" + from types import SimpleNamespace + + from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner + from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner + + kv, st = make_state(num_layers=1, window_chunks=4) + _prepare_and_commit(st, False, 2) # session owns pool blocks + free_before_failure = kv.manager.block_pool.get_num_free_blocks() + + runner = object.__new__(ARDiffusionModelRunner) + runner.kv_cache = kv + runner._ar_diffusion_states = __import__("collections").OrderedDict({"s1": st}) + runner._max_ar_diffusion_states = 4 + runner.pipeline = SimpleNamespace(_states={"s1": object()}, _ar_diffusion_kv_state=None) + runner.device = None + runner._perf_e2e_times = [] + + def boom(self, req): + raise RuntimeError("layer 17 exploded") + + monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom) + req = SimpleNamespace( + request_id="r0", + sampling_params=SimpleNamespace(extra_args={"session_id": "s1"}), + ) + + with pytest.raises(RuntimeError, match="layer 17 exploded"): + ARDiffusionModelRunner.execute_model(runner, req) + + assert "s1" not in runner._ar_diffusion_states + assert "s1" not in runner.pipeline._states + assert runner.pipeline._ar_diffusion_kv_state is None + # close() freed the session's resident blocks. + assert kv.manager.block_pool.get_num_free_blocks() > free_before_failure diff --git a/tests/ar_diffusion/test_slot_mapping.py b/tests/ar_diffusion/test_slot_mapping.py new file mode 100644 index 000000000..061df4d9c --- /dev/null +++ b/tests/ar_diffusion/test_slot_mapping.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Slot-mapping / block-table tests for the AR-Diffusion engine (Phase 1, Step 3).""" + +import pytest +import torch + +from vllm_omni.experimental.ar_diffusion.kv_cache import ( + ARDiffusionRequestAdapter, + ChunkWindowSpec, + build_kv_manager, + chunk_slot_mapping, + compute_slot_mapping, + resident_block_ids, +) + +BLOCK = 16 + + +def make_spec(*, window_chunks=2): + return ChunkWindowSpec( + block_size=BLOCK, + num_kv_heads=4, + head_size=64, + dtype=torch.float16, + sliding_window=window_chunks * BLOCK, + chunk_size=BLOCK, + window_chunks=window_chunks, + ) + + +def test_slot_mapping_matches_block_offsets(): + # block table [5, 2, 9], block_size 4. slot = block*4 + (pos % 4). + slots = compute_slot_mapping([5, 2, 9], [0, 1, 4, 5, 8, 9], block_size=4) + assert slots.tolist() == [20, 21, 8, 9, 36, 37] + + +def test_slot_mapping_rejects_bad_block_size(): + with pytest.raises(ValueError): + compute_slot_mapping([1], [0], block_size=0) + + +def test_chunk_slot_mapping_targets_current_chunk(): + # After 2 committed chunks (num_computed=32), the in-flight chunk maps to the + # block covering positions 32..47 — i.e. block table index 2. + block_ids = [7, 4, 3] # block index 2 -> physical block 3 + slots = chunk_slot_mapping(block_ids, num_computed_tokens=32, chunk_size=4, block_size=16) + # positions 32..35 -> block_index 2 -> physical 3 -> slots 48..51 + assert slots.tolist() == [48, 49, 50, 51] + + +def test_resident_block_ids_excludes_null(): + assert resident_block_ids([0, 1, 0, 2, 3], null_block_id=0) == [1, 2, 3] + + +def test_blocktable_build_single_request(): + """Integration: a real KVCacheManager block table -> a valid slot mapping. + + Every slot for the in-flight chunk must land inside a real (non-null) block + that the manager actually allocated for the request. + """ + spec = make_spec() + mgr = build_kv_manager(spec, ["l0"], num_blocks=16, max_model_len=1024) + null_id = mgr.block_pool.null_block.block_id + adapter = ARDiffusionRequestAdapter("req", chunk_size=BLOCK) + + mgr.allocate_slots(adapter, num_new_tokens=BLOCK) + block_ids = mgr.get_block_ids(adapter.request_id)[0] + real = set(resident_block_ids(block_ids, null_id)) + assert real, "expected at least one real block after allocation" + + slots = chunk_slot_mapping(block_ids, adapter.num_computed_tokens, spec.chunk_size, spec.block_size) + # Each slot resolves to a real allocated block, none to null_block. + blocks_used = {int(s) // spec.block_size for s in slots} + assert blocks_used <= real + assert null_id not in blocks_used + assert len(slots) == spec.chunk_size diff --git a/tests/dreamzero/test_pipeline_state.py b/tests/dreamzero/test_pipeline_state.py index a3a5aaec0..f00caf53c 100644 --- a/tests/dreamzero/test_pipeline_state.py +++ b/tests/dreamzero/test_pipeline_state.py @@ -4,7 +4,6 @@ from collections import OrderedDict import pytest -import torch from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState @@ -46,14 +45,11 @@ def test_dreamzero_pipeline_state_lru_caps_retained_sessions() -> None: assert "session-b" not in pipeline._states -def test_dreamzero_state_cache_access_requires_initialization() -> None: +def test_dreamzero_state_owns_no_kv_caches() -> None: + """KV (self- and cross-attention) is engine-owned since the AR-Diffusion + paged backend: the model-local cache accessors must be gone so nothing can + silently bypass the engine pool.""" state = DreamZeroState() - with pytest.raises(RuntimeError, match="KV caches not initialized"): - state.get_kv_caches() - - with pytest.raises(RuntimeError, match="Cross-attn caches not initialized"): - state.get_crossattn_caches() - - with pytest.raises(RuntimeError, match="create_kv_caches first"): - state.update_kv_cache(0, torch.empty(0)) + for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"): + assert not hasattr(state, removed) diff --git a/vllm_omni/deploy/dreamzero.yaml b/vllm_omni/deploy/dreamzero.yaml index 079101ba0..62b3a5eed 100644 --- a/vllm_omni/deploy/dreamzero.yaml +++ b/vllm_omni/deploy/dreamzero.yaml @@ -14,6 +14,8 @@ stages: max_num_seqs: 1 enforce_eager: false model_class_name: DreamZeroPipeline + # DreamZero runs on the AR-Diffusion engine (engine-level paged KV cache). + engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine model_config: default_robot_embodiment: roboarena policy_server_config: diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index f5fe4c30a..7362e5cf7 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -613,6 +613,20 @@ class OmniDiffusionConfig: distributed_executor_backend: str = "mp" nccl_port: int | None = None + # Engine backend selection, resolved by ``DiffusionEngine.resolve_engine_class`` + # (mirrors ``DiffusionExecutor.get_class``). Config files use a string: + # "default" -> DiffusionEngine, or an import path (set e.g. by a deploy + # config). Programmatic callers may pass a DiffusionEngine subclass + # directly — hence ``str | type`` (structured-config mirrors should expose + # the string form only). + engine_backend: str | type = "default" + + # Optional override for the diffusion model runner class (import path). + # Precedence in the worker: this override > the runner declared by the + # selected engine class (``default_diffusion_model_runner_cls``) > the + # platform default. Never mutated by engines. + diffusion_model_runner_cls: str | None = None + # HuggingFace specific parameters trust_remote_code: bool = False revision: str | None = None @@ -1173,7 +1187,8 @@ class OmniDiffusionConfig: valid_fields = {f.name for f in fields(cls)} filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields} - return cls(**filtered_kwargs) + instance = cls(**filtered_kwargs) + return instance @dataclass diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index 2554099e7..f552ae293 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -134,6 +134,14 @@ class _RpcTask: class DiffusionEngine: """The diffusion engine for vLLM-Omni diffusion models.""" + #: Import path of the model runner this engine's workers should build, or + #: ``None`` for the platform default. Subclasses declare their runner here + #: (e.g. the AR-Diffusion engine), the worker resolves it through + #: :meth:`resolve_engine_class` — so engine->runner routing lives on the + #: engine class itself, and ``od_config.diffusion_model_runner_cls`` + #: remains a pure explicit user override (never mutated by engines). + default_diffusion_model_runner_cls: str | None = None + def __init__( self, od_config: OmniDiffusionConfig, @@ -609,19 +617,55 @@ class DiffusionEngine: self._put_streaming_output_with_cv(request_id, out) @staticmethod - def make_engine( - config: OmniDiffusionConfig, - scheduler: SchedulerInterface | None = None, - ) -> DiffusionEngine: - """Factory method to create a DiffusionEngine instance. + def resolve_engine_class(config: OmniDiffusionConfig) -> type[DiffusionEngine]: + """Resolve the engine class selected by ``config.engine_backend``. + + Mirrors ``DiffusionExecutor.get_class``: accepts ``"default"``, a + ``DiffusionEngine`` subclass, or an import-path string (e.g. a deploy + config's ``engine_backend``). Kept separate from :meth:`make_engine` so the + selection is testable without constructing an engine (which runs a dummy forward). Args: config: The configuration for the diffusion engine. Returns: - An instance of DiffusionEngine. + The ``DiffusionEngine`` (sub)class to instantiate. """ - return DiffusionEngine(config, scheduler=scheduler) + backend = getattr(config, "engine_backend", "default") or "default" + + if isinstance(backend, type): + if not issubclass(backend, DiffusionEngine): + raise TypeError(f"engine_backend must be a DiffusionEngine subclass. Got {backend}.") + return backend + if backend == "default": + return DiffusionEngine + if isinstance(backend, str): + try: + engine_class = resolve_obj_by_qualname(backend) + except (ImportError, ValueError) as e: + raise ValueError( + f"Failed to load engine_backend '{backend}'. Ensure it is a valid python path. Error: {e}" + ) from e + if not issubclass(engine_class, DiffusionEngine): + raise TypeError(f"engine_backend must resolve to a DiffusionEngine subclass. Got {engine_class}.") + return engine_class + raise ValueError(f"Unknown engine_backend: {backend!r}") + + @staticmethod + def make_engine( + config: OmniDiffusionConfig, + scheduler: SchedulerInterface | None = None, + ) -> DiffusionEngine: + """Factory method to create the engine selected by ``config.engine_backend``. + + Args: + config: The configuration for the diffusion engine. + + Returns: + An instance of the resolved ``DiffusionEngine`` (sub)class. + """ + engine_class = DiffusionEngine.resolve_engine_class(config) + return engine_class(config, scheduler=scheduler) def add_request(self, request: OmniDiffusionRequest) -> str: with self._cv: diff --git a/vllm_omni/diffusion/models/dreamzero/causal_wan_model.py b/vllm_omni/diffusion/models/dreamzero/causal_wan_model.py index ad3175e1e..ca9253891 100644 --- a/vllm_omni/diffusion/models/dreamzero/causal_wan_model.py +++ b/vllm_omni/diffusion/models/dreamzero/causal_wan_model.py @@ -37,6 +37,20 @@ from vllm_omni.diffusion.models.dreamzero.action_encoder import ( MultiEmbodimentActionEncoder, ) +# AR-Diffusion paged self-attention (in-tree experimental engine). Import at +# module level so the isinstance check + custom-op call trace cleanly inside +# the fullgraph-compiled DiT block (an import inside the traced region would +# graph-break). The model still works without the engine: the payload type is +# only ever constructed by the AR-Diffusion runner. +try: + from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import ( + ARDiffusionPagedLayerInputs, + paged_write_attn, + ) +except ImportError: # pragma: no cover - experimental package always ships in-tree + ARDiffusionPagedLayerInputs = None + paged_write_attn = None + # ── RoPE utilities ────────────────────────────────────────────────── @@ -510,7 +524,7 @@ class CausalWanSelfAttention(nn.Module): freqs_action: torch.Tensor, freqs_state: torch.Tensor, action_register_length: int | None, - kv_cache: torch.Tensor | None = None, + kv_cache: torch.Tensor | Any | None = None, current_start_frame: int = 0, is_tf: bool = True, ) -> tuple[torch.Tensor, torch.Tensor | None]: @@ -567,24 +581,44 @@ class CausalWanSelfAttention(nn.Module): action_v = v[:, -action_register_length:] v = v[:, :-action_register_length] - updated_k = kv_cache[0] - updated_v = kv_cache[1] - new_k = torch.cat([updated_k, roped_key], dim=1) - new_v = torch.cat([updated_v, v], dim=1) - new_k = new_k[:, -self.max_attention_size :] - new_v = new_v[:, -self.max_attention_size :] - - if action_register_length is not None: - q_cat = torch.cat([roped_query, roped_action_query], dim=1) - k_cat = torch.cat([new_k, roped_action_key], dim=1) - v_cat = torch.cat([new_v, action_v], dim=1) + if ARDiffusionPagedLayerInputs is not None and isinstance(kv_cache, ARDiffusionPagedLayerInputs): + # Fused write+attend custom op: one opaque node in the compiled + # graph (slot writes + FlashAttention block-table kernel inside). + # Metadata tensors were prepared once per forward in _forward_blocks. + if action_register_length is not None: + q_cat = torch.cat([roped_query, roped_action_query], dim=1) + k_act, v_act = roped_action_key[0], action_v[0] + else: + q_cat = roped_query + k_act = v_act = None + x = paged_write_attn( + kv_cache, + q_cat[0], + roped_key[0], + v[0], + k_act, + v_act, + self.head_dim**-0.5, + ).unsqueeze(0) else: - q_cat = roped_query - k_cat = new_k - v_cat = new_v + updated_k = kv_cache[0] + updated_v = kv_cache[1] + new_k = torch.cat([updated_k, roped_key], dim=1) + new_v = torch.cat([updated_v, v], dim=1) + new_k = new_k[:, -self.max_attention_size :] + new_v = new_v[:, -self.max_attention_size :] - x = self.attn(q_cat, k_cat, v_cat) - updated_kv_cache = torch.stack([new_k, new_v], dim=0) + if action_register_length is not None: + q_cat = torch.cat([roped_query, roped_action_query], dim=1) + k_cat = torch.cat([new_k, roped_action_key], dim=1) + v_cat = torch.cat([new_v, action_v], dim=1) + else: + q_cat = roped_query + k_cat = new_k + v_cat = new_v + + x = self.attn(q_cat, k_cat, v_cat) + updated_kv_cache = torch.stack([new_k, new_v], dim=0) x = x.flatten(2) x = self.o(x) @@ -646,7 +680,7 @@ class CausalWanAttentionBlock(nn.Module): freqs_state: torch.Tensor, context: torch.Tensor, action_register_length: int | None = None, - kv_cache: torch.Tensor | None = None, + kv_cache: torch.Tensor | Any | None = None, crossattn_cache: dict | None = None, current_start_frame: int = 0, is_tf: bool = True, @@ -925,10 +959,10 @@ class CausalWanModel(nn.Module): action: torch.Tensor | None, timestep_action: torch.Tensor | None, state: torch.Tensor | None, - kv_cache: list[torch.Tensor], - crossattn_cache: list[dict] | None, + kv_cache: list[Any], current_start_frame: int, - ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor]]: + crossattn_cache: list[dict] | None = None, + ) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor | None]]: x = x.flatten(start_dim=2).transpose(1, 2) B = x.shape[0] F_t = timestep.shape[1] @@ -966,7 +1000,28 @@ class CausalWanModel(nn.Module): clip_embedding = self.img_emb(clip_feature) context = torch.cat([clip_embedding, context], dim=1) - updated_kv_caches: list[torch.Tensor] = [] + # AR-Diffusion paged path: host-side prep ONCE per branch forward, outside + # the compiled blocks — allocate current video/action slots, build the + # padded block-table metadata all 40 layers share, and hand the compiled + # region plain tensors (ARDiffusionPagedLayerInputs) instead of a Python + # context object. Lazy-allocation contract preserved: only the branch this + # CFG-parallel rank executes reaches its _forward_blocks. + if kv_cache and getattr(kv_cache[0], "is_ar_diffusion_paged_context", False): + if B != 1: + raise RuntimeError("AR-Diffusion paged self-attention currently expects batch_size=1") + fctx = kv_cache[0].forward_ctx + if seq_len != fctx.seq_len: + raise RuntimeError( + f"AR-Diffusion paged context seq_len={fctx.seq_len} but current video KV has {seq_len} tokens" + ) + fctx.prepare( + device=x.device, + action_len=int(action_register_length or 0), + query_len=int(x.shape[1]), + ) + kv_cache = [c.to_layer_inputs() for c in kv_cache] + + updated_kv_caches: list[torch.Tensor | None] = [] for block_index, block in enumerate(self.blocks): x, updated_kv_cache = block( x=x, @@ -1000,9 +1055,9 @@ class CausalWanModel(nn.Module): timestep: torch.Tensor, context: torch.Tensor, seq_len: int, - kv_cache: list[torch.Tensor], - crossattn_cache: list[torch.Tensor], + kv_cache: list[Any], current_start_frame: int, + crossattn_cache: list[dict] | None = None, y: torch.Tensor | None = None, clip_feature: torch.Tensor | None = None, action: torch.Tensor | None = None, diff --git a/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py b/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py index 1c1902a71..107638f81 100644 --- a/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py +++ b/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py @@ -3,7 +3,7 @@ """DreamZero pipeline for vllm-omni. -Entry point for DiffusionEngine.step() → pipeline.forward(req) +Entry point for DiffusionEngine.step() -> pipeline.forward(req) """ from __future__ import annotations @@ -93,17 +93,102 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): Multi-output: predict_noise() returns (video_pred, action_pred). CFG: video gets standard CFG, action takes positive branch only. - State: DreamZeroState manages KV cache + frame buffer across forward() calls. + + KV is managed by the AR-Diffusion engine: ``self._ar_diffusion_kv_state`` is set by the + runner before ``forward()`` and the pipeline routes all KV access (get / update / + commit / reset) through the pool-backed state. Purely duck-typed (no engine import). """ + _ar_diffusion_kv_state = None # set by the runner before each forward + + def _kv_get(self, state, is_negative, seq_len=None, update_kv_cache=False): + return self._ar_diffusion_kv_state.get_kv_caches( + is_negative, + seq_len=seq_len, + commit_current=update_kv_cache, + ) + + def _kv_create(self, state, batch_size, dtype, device, num_layers, num_heads, head_dim): + # The engine owns all KV allocation: self-attn is allocated lazily from + # paged contexts, and cross-attn is populated eagerly in _kv_populate_cross. + # Nothing is created model-side. + return + + def _kv_commit(self, is_negative: bool): + self._ar_diffusion_kv_state.commit_paged_context(is_negative) + + def _kv_get_cross(self, state, is_negative): + """Cross-attn cache from the engine pool (text k/v + I2V image k_img/v_img).""" + return self._ar_diffusion_kv_state.get_cross_kv_caches(is_negative) + + def _kv_populate_cross(self, context: torch.Tensor, clip_feature, is_negative: bool) -> None: + """Eagerly project cross-attn K/V for all layers into the AR-Diffusion pool. + + Caches the session-invariant cross-attn projections once, per half: the text + ``k``/``v`` from ``text_embedding(context)`` survive window-boundary resets + (prompt unchanged within a session — only session resets clear them), while + the I2V image-token ``k_img``/``v_img`` from ``img_emb(clip_feature)`` (the + 257 image tokens the forward splits off, cached model-side by #4154) are + re-projected on every window restart from the fresh CLIP features. Must run + after the image is encoded so ``clip_feature`` is available. + """ + s = self._ar_diffusion_kv_state + need_text = not s._cross_text_populated.get(is_negative, False) + need_img = ( + clip_feature is not None + and getattr(self.transformer, "model_type", "t2v") == "i2v" + and not s._cross_img_populated.get(is_negative, False) + ) + if not need_text and not need_img: + return + projected = self.transformer.text_embedding(context) if need_text else None + img_ctx = self.transformer.img_emb(clip_feature) if need_img else None + for i, block in enumerate(self.transformer.blocks): + ca = block.cross_attn + n, d = ca.tp_num_heads, ca.head_dim + k = v = None + if projected is not None: + k = ca.norm_k(ca.k(projected)).unflatten(2, (n, d)) + v = ca.v(projected).unflatten(2, (n, d)) + k_img = v_img = None + if img_ctx is not None: + k_img = ca.norm_k_img(ca.k_img(img_ctx)).unflatten(2, (n, d)) + v_img = ca.v_img(img_ctx).unflatten(2, (n, d)) + s.kv_cache.write_cross_kv(i, is_negative, k, v, k_img, v_img) + if need_text: + s._cross_text_populated[is_negative] = True + if need_img: + s._cross_img_populated[is_negative] = True + logger.info( + "AR-Diffusion CROSS POPULATE [%s]: %d layers, text=%s img=%s", + "neg" if is_negative else "pos", + len(self.transformer.blocks), + "kept" if projected is None else tuple(context.shape), + None if img_ctx is None else tuple(img_ctx.shape), + ) + + def _kv_reset(self, state, *, clear_video_latents: bool = True): + """Reset the engine's pooled session window plus the model's non-KV state. + + DreamZero resets at the attention-window boundary; the engine pool drops the + same window so the next forward starts fresh. ``clear_video_latents=False`` + keeps the accumulated video latents for export. + + ``clear_video_latents=False`` also marks a window ("inference") reset: the + prompt is unchanged, so the pool keeps the text cross-attn K/V and only the + image half repopulates on the restart forward. + """ + state.reset(clear_video_latents=clear_video_latents) + self._ar_diffusion_kv_state.reset(keep_cross_text=not clear_video_latents) + def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None: """Initialize pipeline components. DreamZero root checkpoint layout (GEAR-Dreams/DreamZero-DROID): - config.json — root config (action_head_cfg, architectures, etc.) - model-*.safetensors — all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*) - experiment_cfg/metadata.json — per-embodiment action normalization stats - vae/ — symlink to Wan2.1 VAE (diffusers-compatible) + config.json -- root config (action_head_cfg, architectures, etc.) + model-*.safetensors -- all learned weights (action_head.{model,text_encoder,image_encoder,vae}.*) + experiment_cfg/metadata.json -- per-embodiment action normalization stats + vae/ -- symlink to Wan2.1 VAE (diffusers-compatible) Components are instantiated from config (not from_pretrained), then filled by load_weights() which reads root safetensors and remaps key prefixes. @@ -118,6 +203,18 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): """ super().__init__() + # DreamZero is engine-only: every KV access in forward() routes through + # the AR-Diffusion engine's pool-backed state. Fail fast here — a stale + # or programmatic config that leaves engine_backend="default" would + # otherwise only crash mid-forward on the first KV access. + engine_backend = str(getattr(od_config, "engine_backend", "") or "") + if "ar_diffusion" not in engine_backend.lower().replace("-", "_"): + raise ValueError( + "DreamZeroPipeline requires the AR-Diffusion engine; set " + "engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine " + f"in the deploy config (got engine_backend={engine_backend!r})." + ) + model_path = od_config.model model_config = od_config.model_config local_files_only = os.path.exists(model_path) @@ -228,8 +325,11 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): self.max_action_dim: int = ah_config["max_action_dim"] self.negative_prompt: str = model_config.get("negative_prompt", DEFAULT_NEGATIVE_PROMPT) + # The negative prompt is a model constant: encode it once, lazily, and + # reuse across every forward/session (UMT5 encode is deterministic). + self._negative_prompt_embeds_cache: torch.Tensor | None = None - # Embodiment name → numeric ID mapping (model knowledge) + # Embodiment name -> numeric ID mapping (model knowledge) self.embodiment_name_to_id: dict[str, int] = model_config.get( "embodiment_name_to_id", DEFAULT_EMBODIMENT_NAME_TO_ID, @@ -344,11 +444,17 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): state=kwargs.get("state_features"), embodiment_id=kwargs.get("embodiment_id"), ) - if kwargs.get("update_kv_cache", False) and updated_kv_caches: - state = kwargs.get("dreamzero_state", self.state) + if kwargs.get("update_kv_cache", False): is_neg = kwargs.get("is_negative", False) - for i, kv in enumerate(updated_kv_caches): - state.update_kv_cache(i, kv, is_negative=is_neg) + logger.debug( + "AR-Diffusion pipeline predict_noise -> commit paged context: " + "is_neg=%s seq_len=%s current_start_frame=%s layers=%d", + is_neg, + kwargs.get("seq_len"), + kwargs.get("current_start_frame"), + len(updated_kv_caches), + ) + self._kv_commit(is_neg) return video_pred, action_pred @@ -362,6 +468,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): Paper D.2 uses ``mode=reduce-overhead``, ``fullgraph=True``, ``dynamic=False`` on text/image/VAE and DiT. VAE decode uses a tensor feat_cache patch and compiles ``decoder.forward`` (not ``_decode``, which has a Python frame loop). + Incremental VAE encode (``_vae_encode_encoder_chunk``) stays eager because + Wan ``feat_cache`` mutation is incompatible with CUDAGraph capture. DiT blocks use per-block ``fullgraph=True``. """ if not torch.cuda.is_available(): @@ -376,6 +484,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): compile_ro = {"mode": "reduce-overhead", "fullgraph": True, "dynamic": False} # DiT blocks: default avoids CUDAGraph overwrite on modulation tensors; encoders use reduce-overhead. + # The AR-Diffusion paged self-attention is a registered custom op, so the + # block stays fullgraph even on that path. dit_compile = {"mode": "default", "fullgraph": True, "dynamic": False} logger.info( @@ -396,9 +506,9 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): logger.warning("DreamZero: image_encoder compile failed (%s); skipping.", exc) try: - self.vae.encode = torch.compile(self.vae.encode, **compile_ro) + self.vae._encode = torch.compile(self.vae._encode, **compile_ro) except Exception as exc: - logger.warning("DreamZero: vae.encode compile failed (%s); skipping.", exc) + logger.warning("DreamZero: vae._encode compile failed (%s); skipping.", exc) compiled_blocks = 0 for block in self.transformer.blocks: @@ -437,10 +547,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): try: image = torch.zeros(1, 1, 3, 180, 320, dtype=torch.bfloat16, device=device) - self._encode_image(image, self.num_frames, 180, 320) + self._encode_image(image, self.num_frames, 180, 320, state=self.state) except Exception as exc: logger.warning("DreamZero compile warmup (image_encoder) skipped: %s", exc) + try: + self.state.reset_vae_encoder_stream() + dummy_video = torch.zeros(1, 3, 1, 180, 320, dtype=torch.bfloat16, device=device) + self._vae_stream_seed(self.state, dummy_video[:, :, :1]) + for _ in range(4): + self._vae_stream_append_frame(self.state, dummy_video[:, :, :1]) + self._vae_stream_get_observation_latents( + self.state, + self.num_frame_per_block, + dtype=torch.bfloat16, + ) + except Exception as exc: + logger.warning("DreamZero compile warmup (vae encode stream) skipped: %s", exc) + try: latent_h, latent_w = 180 // 8, 320 // 8 dummy_latent = torch.zeros( @@ -499,7 +623,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): # ----------------------------------------------------------------------- def _preprocess_video(self, videos: torch.Tensor) -> torch.Tensor: - """uint8 [B,T,H,W,C] → bfloat16 [B,C,T,H,W] normalized to [-1,1].""" + """uint8 [B,T,H,W,C] -> bfloat16 [B,C,T,H,W] normalized to [-1,1].""" videos = videos.permute(0, 4, 1, 2, 3) if videos.dtype == torch.uint8: videos = videos.float() / 255.0 @@ -546,6 +670,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): num_frames: int, height: int, width: int, + *, + state: DreamZeroState | None = None, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Encode first frame via CLIP + VAE. Returns: (clip_feas, ys, image_latent) @@ -581,11 +707,147 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): new_image = y[:, :, 0:1] y = torch.concat([msk, y], dim=1) + if state is not None: + if not state.vae_stream_initialized: + # Seed the AR streaming encoder after the compiled full encode above; + # ``cudagraph_mark_step_begin`` isolates eager feat_cache work from CUDAGraph. + self._vae_stream_seed(state, image_input[:, :, :1]) + else: + # Window ("inference") restart with a live stream: keep the real + # frame history in the Wan feat_cache and append the restart + # observation instead of reseeding from scratch. + self._cudagraph_mark_step_begin() + self._vae_stream_append_frame(state, image_input[:, :, :1]) + return clip_context, y, new_image + def _vae_patchify(self, videos: torch.Tensor) -> torch.Tensor: + if self.vae.config.patch_size is not None: + from diffusers.models.autoencoders.autoencoder_kl_wan import patchify + + return patchify(videos, patch_size=self.vae.config.patch_size) + return videos + + @staticmethod + def _vae_clone_feat_map(feat_map: list[torch.Tensor | None]) -> list[torch.Tensor | None]: + return [entry.clone() if isinstance(entry, torch.Tensor) else entry for entry in feat_map] + + def _vae_init_enc_feat_map(self) -> list[torch.Tensor | None]: + self.vae.clear_cache() + return self._vae_clone_feat_map(self.vae._enc_feat_map) + + def _vae_encode_encoder_chunk( + self, + chunk: torch.Tensor, + feat_map: list[torch.Tensor | None], + ) -> tuple[torch.Tensor, list[torch.Tensor | None]]: + """Run one Wan encoder chunk while mutating ``feat_map`` in place. + + Must stay eager (not ``torch.compile``): Wan causal ``feat_cache`` updates + conflict with ``reduce-overhead`` CUDAGraph capture. + """ + self.vae._enc_feat_map = feat_map + self.vae._enc_conv_idx = [0] + out = self.vae.encoder( + chunk, + feat_cache=self.vae._enc_feat_map, + feat_idx=self.vae._enc_conv_idx, + ) + return out, self._vae_clone_feat_map(self.vae._enc_feat_map) + + def _vae_quantize_encoder_out(self, encoder_out: torch.Tensor) -> torch.Tensor: + enc = self.vae.quant_conv(encoder_out) + mu, _ = enc.chunk(2, dim=1) + mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype) + inv_std = self.vae_latents_inv_std.to(device=mu.device, dtype=mu.dtype) + return (mu - mean) * inv_std + + def _vae_stream_seed(self, state: DreamZeroState, first_frame: torch.Tensor) -> None: + """Seed incremental VAE encode with the first observation frame.""" + state.reset_vae_encoder_stream() + feat_map = self._vae_init_enc_feat_map() + chunk = self._vae_patchify(first_frame.to(dtype=self.vae.dtype)) + self._cudagraph_mark_step_begin() + encoder_out, feat_map = self._vae_encode_encoder_chunk(chunk[:, :, :1], feat_map) + state.vae_enc_feat_map = feat_map + state.vae_encoder_out = encoder_out + state.vae_pending_body_frames = None + state.vae_stream_initialized = True + + def _vae_stream_append_frame(self, state: DreamZeroState, new_frame: torch.Tensor) -> None: + """Append one pixel frame and encode a 4-frame body chunk when ready.""" + if not state.vae_stream_initialized or state.vae_enc_feat_map is None or state.vae_encoder_out is None: + raise RuntimeError("VAE encoder stream is not initialized.") + + frame = new_frame.to(dtype=self.vae.dtype) + if state.vae_pending_body_frames is None: + state.vae_pending_body_frames = frame + else: + state.vae_pending_body_frames = torch.cat([state.vae_pending_body_frames, frame], dim=2) + + while state.vae_pending_body_frames is not None and state.vae_pending_body_frames.shape[2] >= 4: + body = state.vae_pending_body_frames[:, :, :4] + if state.vae_pending_body_frames.shape[2] > 4: + state.vae_pending_body_frames = state.vae_pending_body_frames[:, :, 4:] + else: + state.vae_pending_body_frames = None + chunk = self._vae_patchify(body) + encoder_chunk, feat_map = self._vae_encode_encoder_chunk(chunk, state.vae_enc_feat_map) + state.vae_encoder_out = torch.cat([state.vae_encoder_out, encoder_chunk], dim=2) + state.vae_enc_feat_map = feat_map + + def _vae_stream_get_observation_latents( + self, + state: DreamZeroState, + num_latent_frames: int, + *, + dtype: torch.dtype, + ) -> torch.Tensor: + if state.vae_encoder_out is None: + raise RuntimeError("VAE encoder stream has no accumulated encoder output.") + latents = self._vae_quantize_encoder_out(state.vae_encoder_out).to(dtype=dtype) + if latents.shape[2] >= num_latent_frames: + return latents[:, :, -num_latent_frames:] + pad_count = num_latent_frames - latents.shape[2] + pad = latents[:, :, -1:].expand(-1, -1, pad_count, -1, -1) + return torch.cat([pad, latents], dim=2) + + def _preprocess_vae_observation_window(self, videos: torch.Tensor) -> torch.Tensor: + _, _, num_frames_raw, _, _ = videos.shape + if (num_frames_raw - 1) // 4 == self.num_frame_per_block: + return videos + if num_frames_raw // 4 != self.num_frame_per_block: + repeat_factor = self.num_frame_per_block // (num_frames_raw // 4) + videos = torch.repeat_interleave(videos, repeat_factor, dim=2) + first_frame = videos[:, :, 0:1] + return torch.cat([first_frame, videos], dim=2) + first_frame = videos[:, :, 0:1] + return torch.cat([first_frame, videos], dim=2) + + def _encode_observation_latents( + self, + state: DreamZeroState, + videos: torch.Tensor, + *, + latent_dtype: torch.dtype, + ) -> torch.Tensor: + """Encode current robot observation into normalized VAE latents.""" + if state.vae_stream_initialized: + self._cudagraph_mark_step_begin() + self._vae_stream_append_frame(state, videos[:, :, -1:]) + return self._vae_stream_get_observation_latents( + state, + self.num_frame_per_block, + dtype=latent_dtype, + ) + + videos = self._preprocess_vae_observation_window(videos) + return self._encode_vae_latents(videos).to(dtype=latent_dtype) + def _encode_vae_latents(self, videos: torch.Tensor) -> torch.Tensor: """Encode videos into normalized VAE latents.""" input_dtype = videos.dtype + self._cudagraph_mark_step_begin() hidden = self.vae._encode(videos.to(dtype=self.vae.dtype)) mu, _ = hidden.chunk(2, dim=1) mean = self.vae_latents_mean.to(device=mu.device, dtype=mu.dtype) @@ -635,7 +897,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): ) -> None: """Prefill KV cache with first frame and/or current observation. - Uses predict_noise_maybe_with_cfg() for CFG parallel — same path as + Uses predict_noise_maybe_with_cfg() for CFG parallel -- same path as the denoise loop. The mixin handles rank dispatch automatically. KV cache update happens as a side effect inside predict_noise(). """ @@ -646,7 +908,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): head_dim = self.transformer.dim // self.transformer.num_heads if state.current_start_frame == 0: - state.create_kv_caches( + self._kv_create( + state, batch_size, dtype, device, @@ -671,16 +934,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): ) positive_kwargs = dict( encoder_hidden_states=prompt_embeds, - kv_cache=state.get_kv_caches(False), - crossattn_cache=state.get_crossattn_caches(False), + kv_cache=self._kv_get(state, False, seq_len=frame_seqlen, update_kv_cache=True), + crossattn_cache=self._kv_get_cross(state, False), is_negative=False, **common, ) negative_kwargs = ( dict( encoder_hidden_states=negative_prompt_embeds, - kv_cache=state.get_kv_caches(True), - crossattn_cache=state.get_crossattn_caches(True), + kv_cache=self._kv_get(state, True, seq_len=frame_seqlen, update_kv_cache=True), + crossattn_cache=self._kv_get_cross(state, True), is_negative=True, **common, ) @@ -721,16 +984,16 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): ) positive_kwargs = dict( encoder_hidden_states=prompt_embeds, - kv_cache=state.get_kv_caches(False), - crossattn_cache=state.get_crossattn_caches(False), + kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=True), + crossattn_cache=self._kv_get_cross(state, False), is_negative=False, **common, ) negative_kwargs = ( dict( encoder_hidden_states=negative_prompt_embeds, - kv_cache=state.get_kv_caches(True), - crossattn_cache=state.get_crossattn_caches(True), + kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=True), + crossattn_cache=self._kv_get_cross(state, True), is_negative=True, **common, ) @@ -763,8 +1026,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): For each timestep: 1. Build positive_kwargs / negative_kwargs - 2. predict_noise_maybe_with_cfg() → (video_pred, action_pred) - 3. scheduler_step_maybe_with_cfg() → VideoActionScheduler + 2. predict_noise_maybe_with_cfg() -> (video_pred, action_pred) + 3. scheduler_step_maybe_with_cfg() -> VideoActionScheduler 4. _synchronize_cfg_parallel_step_output() """ seq_len = kwargs["seq_len"] @@ -823,8 +1086,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): hidden_states=noisy_input.transpose(1, 2), timestep_video=timestep, encoder_hidden_states=prompt_embeds, - kv_cache=state.get_kv_caches(False), - crossattn_cache=state.get_crossattn_caches(False), + kv_cache=self._kv_get(state, False, seq_len=seq_len, update_kv_cache=False), + crossattn_cache=self._kv_get_cross(state, False), y=y, clip_feature=state.clip_feas, action=noisy_input_action, @@ -838,8 +1101,8 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): hidden_states=noisy_input.transpose(1, 2), timestep_video=timestep, encoder_hidden_states=negative_prompt_embeds, - kv_cache=state.get_kv_caches(True), - crossattn_cache=state.get_crossattn_caches(True), + kv_cache=self._kv_get(state, True, seq_len=seq_len, update_kv_cache=False), + crossattn_cache=self._kv_get_cross(state, True), y=y, clip_feature=state.clip_feas, action=noisy_input_action, @@ -936,7 +1199,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): device=device, ) - # State: raw from transform → pad to (B, state_horizon=1, max_state_dim) + # State: raw from transform -> pad to (B, state_horizon=1, max_state_dim) raw_state = unified_obs["state"] state_for_postprocess = None if raw_state is not None: @@ -976,38 +1239,66 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): # Explicit reset from OpenPI serving is carried by `extra_args["reset"]` # on the next inference request after websocket reset/session switch. if extra_args.get("reset", False): - state.reset() + self._kv_reset(state) else: + # Auto-reset based on model state (before accumulation). Both a "session" + # reset and an "inference" (window-boundary) reset clear the model-local + # KV window, so route both through _kv_reset to drop the AR-Diffusion pool window + # too and stay parity-exact; "inference" keeps the accumulated video + # latents (reset_inference_state == reset(clear_video_latents=False)). reset_reason = state.reset_reason(text_tokens, 0, self.transformer.local_attn_size) if reset_reason == "session": - state.reset() + self._kv_reset(state) elif reset_reason == "inference": - state.reset_inference_state() + self._kv_reset(state, clear_video_latents=False) state.language = text_tokens - # Frame accumulation: stitched single frame → multi-frame video + # Frame accumulation: stitched single frame -> multi-frame video video_frames = state.accumulate_frames(stitched) # (T, H, W, C) videos = torch.from_numpy(video_frames).unsqueeze(0).to(device) # (B=1, T, H, W, C) - videos = self._preprocess_video(videos) # → [B,C,T,H,W] bf16 + videos = self._preprocess_video(videos) # -> [B,C,T,H,W] bf16 _, _, num_frames_raw, height, width = videos.shape - prompt_embeds = self._encode_text(text_tokens, attention_mask) + # Optional phase timing (DZ_PHASE_TIMING=1): logs per-forward stage costs + # (text encode / obs VAE encode / KV prefill / denoise) at INFO. Each mark + # synchronizes CUDA, so leave it off for timed benchmark runs. + _pt = None + if os.environ.get("DZ_PHASE_TIMING"): + import time as _time + + torch.accelerator.synchronize() + _pt = {"time": _time, "t0": _time.perf_counter(), "marks": []} + + def _pt_mark(name: str) -> None: + if _pt is not None: + torch.accelerator.synchronize() + _pt["marks"].append((name, _pt["time"].perf_counter())) + + # Prompt embeds are constant within a session (a prompt change triggers a + # "session" reset above, which clears this cache alongside state.language). + if state.prompt_embeds is None: + state.prompt_embeds = self._encode_text(text_tokens, attention_mask) + prompt_embeds = state.prompt_embeds # Negative prompt for CFG uncond branch (model constant) negative_prompt_embeds = None if self.cfg_scale > 1.0: - neg_inputs = self.tokenizer( - self.negative_prompt, - max_length=512, - padding="max_length", - truncation=True, - return_tensors="pt", - add_special_tokens=True, - ) - negative_prompt_embeds = self._encode_text( - neg_inputs["input_ids"].to(device), - neg_inputs["attention_mask"].to(device), - ) + if self._negative_prompt_embeds_cache is None: + neg_inputs = self.tokenizer( + self.negative_prompt, + max_length=512, + padding="max_length", + truncation=True, + return_tensors="pt", + add_special_tokens=True, + ) + self._negative_prompt_embeds_cache = self._encode_text( + neg_inputs["input_ids"].to(device), + neg_inputs["attention_mask"].to(device), + ) + negative_prompt_embeds = self._negative_prompt_embeds_cache + + _pt_mark("text_encode") # Extract first/last frame for CLIP + VAE encoding if num_frames_raw == 4 or num_frames_raw == 9: @@ -1021,27 +1312,24 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): self.num_frames, height, width, + state=state, ) state.clip_feas = clip_feas.to(dtype=image.dtype) state.ys = ys.to(dtype=image.dtype) - if state.current_start_frame != 0: - # Subsequent calls: encode current observation via VAE - if (num_frames_raw - 1) // 4 == self.num_frame_per_block: - pass - elif num_frames_raw // 4 != self.num_frame_per_block: - repeat_factor = self.num_frame_per_block // (num_frames_raw // 4) - videos = torch.repeat_interleave(videos, repeat_factor, dim=2) - first_frame = videos[:, :, 0:1] - videos = torch.cat([first_frame, videos], dim=2) - else: - first_frame = videos[:, :, 0:1] - videos = torch.cat([first_frame, videos], dim=2) + # Eager cross-attn population (AR-Diffusion only): cache text + image-token K/V + # into the pool now that the image is encoded (clip_feas available). + # Runs on the first forward of a session and after each window-boundary + # reset (current_start_frame returns to 0); the populate guards (text/img halves) gate re-entry. + self._kv_populate_cross(prompt_embeds, state.clip_feas, is_negative=False) + if negative_prompt_embeds is not None: + self._kv_populate_cross(negative_prompt_embeds, state.clip_feas, is_negative=True) + if state.current_start_frame != 0: latent_dtype = videos.dtype with torch.no_grad(): - image = self._encode_vae_latents(videos) - image = image.to(dtype=latent_dtype) + image = self._encode_observation_latents(state, videos, latent_dtype=latent_dtype) + _pt_mark("obs_vae_encode") batch_size = image.shape[0] generator = torch.Generator(device=device).manual_seed(self.seed) @@ -1082,6 +1370,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): do_true_cfg, state, ) + _pt_mark("prefill_kv") sample_scheduler = copy.deepcopy(self.scheduler) sample_scheduler_action = copy.deepcopy(self.scheduler) @@ -1123,6 +1412,19 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): state_features=state_features, embodiment_id=embodiment_id, ) + if _pt is not None: + _pt_mark("diffuse") + prev_t = _pt["t0"] + parts = [] + for name, t in _pt["marks"]: + parts.append(f"{name}={1000 * (t - prev_t):.1f}ms") + prev_t = t + logger.info( + "DZ_PHASE_TIMING csf=%s total=%.1fms %s", + state.current_start_frame, + 1000 * (prev_t - _pt["t0"]), + " ".join(parts), + ) if state.current_start_frame == 1: video_out = torch.cat([image, video_out], dim=1) @@ -1133,7 +1435,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): # q99 denorm: [-1,1] → real values action_out = self._denormalize_action(action_out.float(), embodiment_name) - # Relative → absolute: only for relative_action_keys (joint_position only) + # Relative -> absolute: only for relative_action_keys (joint_position only) # gripper_position is NOT relative, so don't add state back to it if self.relative_action and state_for_postprocess is not None: n_relative = self.relative_action_dim # 7 for DROID (joint only) @@ -1145,7 +1447,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): action_out[..., :n_relative] + last_state.unsqueeze(1) # broadcast over horizon ) - # Squeeze batch dim for output: (B, horizon, dim) → (horizon, dim) + # Squeeze batch dim for output: (B, horizon, dim) -> (horizon, dim) actions_np = action_out.squeeze(0).float().cpu().numpy() # (horizon, max_action_dim) actions_np = transform.transform_action_output(actions_np) @@ -1245,7 +1547,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): q01 = stats["q01"].to(device=action.device, dtype=action.dtype) q99 = stats["q99"].to(device=action.device, dtype=action.dtype) # action shape: (B, horizon, action_dim) or (B, horizon, max_action_dim) - # q01/q99 shape: (actual_action_dim,) — only denorm actual dims + # q01/q99 shape: (actual_action_dim,) -- only denorm actual dims actual_dim = q01.shape[0] action_real = action.clone() action_real[..., :actual_dim] = (action[..., :actual_dim] + 1) / 2 * (q99 - q01) + q01 diff --git a/vllm_omni/diffusion/models/dreamzero/state_dreamzero.py b/vllm_omni/diffusion/models/dreamzero/state_dreamzero.py index 5815d93d4..4c9209808 100644 --- a/vllm_omni/diffusion/models/dreamzero/state_dreamzero.py +++ b/vllm_omni/diffusion/models/dreamzero/state_dreamzero.py @@ -115,17 +115,24 @@ class DreamZeroState: # Concatenated along T before decode (matches upstream video_across_time). self.video_latents_across_time = saved_video_latents - # KV cache once robot-policy diffusion supports that integration. - self.kv_cache = None - self.kv_cache_neg = None - self.crossattn_cache = None - self.crossattn_cache_neg = None self.current_start_frame = 0 self.clip_feas = None self.ys = None if clear_video_latents: + # Session reset: drop the prompt-embed cache and VAE encoder stream. self.language = None + self.prompt_embeds = None + self.reset_vae_encoder_stream() + # Window ("inference") resets keep both: the prompt is unchanged, and the + # Wan feat_cache history is independent of the DiT attention window. + + def reset_vae_encoder_stream(self) -> None: + """Clear incremental Wan VAE encoder state used across AR steps.""" + self.vae_stream_initialized = False + self.vae_enc_feat_map: list[torch.Tensor | None] | None = None + self.vae_encoder_out: torch.Tensor | None = None + self.vae_pending_body_frames: torch.Tensor | None = None def reset_inference_state(self) -> None: """Reset KV/frame state after local attention rolls without dropping video latents.""" @@ -166,57 +173,3 @@ class DreamZeroState: def should_reset(self, text_tokens: torch.Tensor | None, num_video_frames: int, local_attn_size: int) -> bool: """Determine if state should be reset before this forward().""" return self.reset_reason(text_tokens, num_video_frames, local_attn_size) is not None - - # ------------------------------------------------------------------ - # KV cache management - # ------------------------------------------------------------------ - - def create_kv_caches( - self, - batch_size: int, - dtype: torch.dtype, - device: torch.device, - num_layers: int, - num_heads: int, - head_dim: int, - ) -> None: - """Initialize empty KV caches and cross-attention caches.""" - self.kv_cache = [ - torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers) - ] - self.kv_cache_neg = [ - torch.zeros(2, batch_size, 0, num_heads, head_dim, dtype=dtype, device=device) for _ in range(num_layers) - ] - - self.crossattn_cache = [ - {"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers) - ] - self.crossattn_cache_neg = [ - {"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None} for _ in range(num_layers) - ] - - def update_kv_cache( - self, - layer_index: int, - updated_kv: torch.Tensor, - is_negative: bool = False, - ) -> None: - """Update a single layer's KV cache after prefill.""" - cache = self.kv_cache_neg if is_negative else self.kv_cache - if cache is None: - raise RuntimeError("KV caches not initialized, call create_kv_caches first.") - cache[layer_index] = updated_kv.clone() - - def get_kv_caches(self, is_negative: bool = False) -> list[torch.Tensor]: - """Get KV caches for the specified branch.""" - cache = self.kv_cache_neg if is_negative else self.kv_cache - if cache is None: - raise RuntimeError("KV caches not initialized.") - return cache - - def get_crossattn_caches(self, is_negative: bool = False) -> list[dict[str, bool | torch.Tensor | None]]: - """Get cross-attention caches for the specified branch.""" - cache = self.crossattn_cache_neg if is_negative else self.crossattn_cache - if cache is None: - raise RuntimeError("Cross-attn caches not initialized.") - return cache diff --git a/vllm_omni/diffusion/models/dreamzero/video_export_worker.py b/vllm_omni/diffusion/models/dreamzero/video_export_worker.py index d039e53c8..ba980a33e 100644 --- a/vllm_omni/diffusion/models/dreamzero/video_export_worker.py +++ b/vllm_omni/diffusion/models/dreamzero/video_export_worker.py @@ -9,6 +9,35 @@ import torch class DreamZeroVideoExportWorkerExtension: """DreamZero worker RPCs used by offline example video export.""" + def gpu_mem_stats(self) -> dict: + """Peak GPU memory (GiB) for this worker's device, for profiling. + + ``max_memory_reserved`` is the caching-allocator high-water mark (the best + in-process proxy for "peak VRAM"); ``max_memory_allocated`` is the live + tensor high-water mark. Both are monotonic from process start, so the + end-of-run values capture the whole rollout's peak. + """ + dev = torch.accelerator.current_device_index() + return { + "device": int(dev), + "peak_reserved_gib": torch.accelerator.max_memory_reserved(dev) / (1024**3), + "peak_allocated_gib": torch.accelerator.max_memory_allocated(dev) / (1024**3), + } + + def ar_diffusion_perf_stats(self, reset: bool = True) -> dict: + """Per-request server-side forward E2E times (s) recorded by the AR-Diffusion runner. + + The worker-side compute time per ``execute_model`` — the analog of the + upstream server's per-request E2E — which excludes the engine<->worker IPC + that the client's ``omni.generate`` wall time includes. Returns ``[]`` when + the runner is the base (KV-disabled) runner that does not record timings. + """ + runner = self.model_runner + times = list(getattr(runner, "_perf_e2e_times", []) or []) + if reset and hasattr(runner, "_perf_e2e_times"): + runner._perf_e2e_times = [] + return {"server_e2e_s": times} + @staticmethod def _latents_to_uint8_frames(decoded: torch.Tensor) -> torch.Tensor: decoded = decoded.squeeze(0).permute(1, 2, 3, 0).contiguous() diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index 172f48cf1..8cce47897 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -213,8 +213,32 @@ class DiffusionWorker: self._step_lora_state: dict[str, tuple[LoRARequest | None, float]] = {} self.stage_id = getattr(od_config, "stage_id", 0) self.init_device() - # Create model runner using the platform-specified class - model_runner_cls_path = current_omni_platform.get_diffusion_model_runner_cls() + # Create model runner — one decision chain, in precedence order: + # 1. explicit od_config.diffusion_model_runner_cls (user override), + # 2. the runner declared by the engine class that engine_backend + # selects (e.g. ARDiffusionEngine -> ARDiffusionModelRunner), + # 3. the platform default. + # Routing policy therefore lives on the engine class / config surface; + # engines never mutate od_config. Overrides must be import-path + # strings — guard with isinstance so a non-string (e.g. a Mock + # od_config in tests) doesn't shadow the platform hook. + runner_override = getattr(self.od_config, "diffusion_model_runner_cls", None) + engine_runner = None + if not (isinstance(runner_override, str) and runner_override): + try: + from vllm_omni.diffusion.diffusion_engine import DiffusionEngine + + engine_cls = DiffusionEngine.resolve_engine_class(self.od_config) + engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None) + except Exception: + logger.warning("Worker %s: engine_backend resolution failed; using platform runner", self.rank) + engine_runner = None + if isinstance(runner_override, str) and runner_override: + model_runner_cls_path = runner_override + elif isinstance(engine_runner, str) and engine_runner: + model_runner_cls_path = engine_runner + else: + model_runner_cls_path = current_omni_platform.get_diffusion_model_runner_cls() model_runner_cls = resolve_obj_by_qualname(model_runner_cls_path) self.model_runner = model_runner_cls( vllm_config=self.vllm_config, diff --git a/vllm_omni/experimental/__init__.py b/vllm_omni/experimental/__init__.py index f8b58d229..c7d59e2e2 100644 --- a/vllm_omni/experimental/__init__.py +++ b/vllm_omni/experimental/__init__.py @@ -1,3 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Experimental vLLM-Omni features (APIs may change without notice).""" +"""Experimental vLLM-Omni subsystems. + +Modules under ``vllm_omni.experimental`` are in active development and not yet +covered by the stability guarantees of the rest of the package — their APIs, +config surfaces, and defaults may change without notice. The AR-Diffusion +Engine (:mod:`vllm_omni.experimental.ar_diffusion`) lives here. +""" diff --git a/vllm_omni/experimental/ar_diffusion/__init__.py b/vllm_omni/experimental/ar_diffusion/__init__.py new file mode 100644 index 000000000..b312660ec --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/__init__.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +"""AR-Diffusion Engine (AR-Diffusion). + +The AR-Diffusion engine: a ``DiffusionEngine`` subclass that adds engine-level KV +cache management for autoregressive / chunked "world-model" diffusion models +(DreamZero and the AR-DiT family). Selected via +``OmniDiffusionConfig.engine_backend = "ar_diffusion"``. +""" + +from vllm_omni.experimental.ar_diffusion.engine import ARDiffusionEngine + +__all__ = ["ARDiffusionEngine"] diff --git a/vllm_omni/experimental/ar_diffusion/engine.py b/vllm_omni/experimental/ar_diffusion/engine.py new file mode 100644 index 000000000..6fb6ee7c9 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/engine.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The AR-Diffusion Engine (AR-Diffusion).""" + +from __future__ import annotations + +from vllm.logger import init_logger + +from vllm_omni.diffusion.diffusion_engine import DiffusionEngine + +logger = init_logger(__name__) + +#: Import path of the runner the AR-Diffusion engine routes its workers to. +AR_DIFFUSION_MODEL_RUNNER_CLS = "vllm_omni.experimental.ar_diffusion.runner.ARDiffusionModelRunner" + + +class ARDiffusionEngine(DiffusionEngine): + """AR-Diffusion engine with engine-level KV cache management. + + AR-Diffusion serves autoregressive / chunked blockwise-causal diffusion models + (world models, AR-DiT) that materialize persistent attention KV. It reuses + vLLM's paged KV stack (``KVCacheManager`` / ``BlockPool`` / ``BlockTables``) + as a library, driven from the engine rather than hand-rolled inside each + model. See ``BDE_doc/diffusion_kv_cache_management.md`` for the design and + ``BDE_doc/dreamzero_kv_phase1_plan.md`` for the rollout. + + It is selected per model via ``OmniDiffusionConfig.engine_backend = "ar_diffusion"`` + (resolved by :meth:`DiffusionEngine.make_engine`), so models that do not opt + in keep using the base ``DiffusionEngine`` unchanged. + + Architecture note: in the multiproc setup the KV cache lives in the worker / + runner process (GPU side), co-located with the model and KV tensors — so the + actual KV *body* is :class:`~vllm_omni.experimental.ar_diffusion.kv_cache.manager.ARDiffusionKVCache`, owned + by :class:`~vllm_omni.experimental.ar_diffusion.runner.ARDiffusionModelRunner`. ``ARDiffusionEngine`` itself is the + thin selection / injection seam; it wires the AR-Diffusion executor → worker → runner + so DreamZero's rollout runs against the runner-owned KV cache. + """ + + #: Workers of this engine build the AR-Diffusion runner (unless the config + #: sets an explicit ``diffusion_model_runner_cls`` override). Declared on + #: the class so routing needs no od_config mutation at engine init. + default_diffusion_model_runner_cls = AR_DIFFUSION_MODEL_RUNNER_CLS diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/__init__.py b/vllm_omni/experimental/ar_diffusion/kv_cache/__init__.py new file mode 100644 index 000000000..ab29f6093 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/__init__.py @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: Apache-2.0 +"""AR-Diffusion engine-level KV cache helpers. + +Thin glue over vLLM's paged KV stack (``KVCacheManager`` / ``BlockPool`` / +``SlidingWindowManager``), used by the AR-Diffusion Engine to manage KV for +AR-diffusion models. See ``BDE_doc/dreamzero_kv_phase1_plan.md``. + +Layout: ``config`` (public knob) · ``paged`` (engine-generic paging mechanics + +chunk-window eviction spec) · ``manager`` (the ARDiffusionKVCache orchestrator + its +request adapter / pool builders) · ``state`` (the model-facing ARDiffusionKVState bridge). +""" + +from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig +from vllm_omni.experimental.ar_diffusion.kv_cache.manager import ( + ARDiffusionKVCache, + ARDiffusionRequestAdapter, + build_kv_manager, + compute_num_blocks, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.paged import ( + ChunkWindowManager, + ChunkWindowSpec, + allocate_kv_pool_with_views, + chunk_slot_mapping, + compute_slot_mapping, + pool_write_chunk, + resident_block_ids, +) +from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import ( + ARDiffusionPagedForwardContext, + ARDiffusionPagedLayerContext, + ARDiffusionPagedLayerInputs, + ar_diffusion_paged_attention, + paged_write_attn, + set_current_paged_kv_cache, +) + +__all__ = [ + "ARDiffusionKVCache", + "ARDiffusionKVConfig", + "ARDiffusionPagedForwardContext", + "ARDiffusionPagedLayerContext", + "ARDiffusionPagedLayerInputs", + "ARDiffusionRequestAdapter", + "ChunkWindowManager", + "ChunkWindowSpec", + "allocate_kv_pool_with_views", + "ar_diffusion_paged_attention", + "build_kv_manager", + "chunk_slot_mapping", + "compute_num_blocks", + "compute_slot_mapping", + "paged_write_attn", + "pool_write_chunk", + "resident_block_ids", + "set_current_paged_kv_cache", +] diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/config.py b/vllm_omni/experimental/ar_diffusion/kv_cache/config.py new file mode 100644 index 000000000..4b3c73775 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/config.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Configuration for AR-Diffusion engine-level KV cache management.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ARDiffusionKVConfig: + """Settings for the AR-Diffusion paged KV cache. + + Disabled by default: when ``enable`` is False the AR-Diffusion engine behaves exactly + like the base ``DiffusionEngine`` (no pool, no paged KV). + """ + + enable: bool = False + # Persistent KV tokens materialized per AR chunk. For DreamZero this is + # ``num_frame_per_block * frame_seqlen``. + chunk_size: int = 0 + # Resident window in chunks. ``None`` means full attention (no eviction). + window_chunks: int | None = None + # Protected leading chunks (attention sink); never evicted. + sink_chunks: int = 0 + # DreamZero-style window reset vs. VGGT-style sliding replace. + reset_at_boundary: bool = False + # Fraction of free device memory budgeted for the AR-Diffusion KV pool. + gpu_memory_fraction: float = 0.1 + # When CUDA graph / torch.compile is on (not enforce_eager), pre-capture the + # DiT graphs for every window-fill shape at load time via a synthetic rollout, + # so the serving run is fast from the first chunk. No effect when eager. + warmup_cudagraph: bool = True + # Also capture the post-window-boundary (reset-cycle) forward during warm-up. + warmup_capture_reset: bool = False + + @property + def sliding_window(self) -> int | None: + """Window size in tokens, or ``None`` for full attention.""" + if self.window_chunks is None: + return None + return self.window_chunks * self.chunk_size diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py new file mode 100644 index 000000000..4b3b5d788 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py @@ -0,0 +1,497 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ARDiffusionKVCache — the engine-level KV cache orchestrator for one AR-Diffusion model. + +This is the *body* of AR-Diffusion's KV management: it owns a vLLM ``KVCacheManager`` (a +single chunk-window group) and the per-request adapter lifecycle, and exposes the +per-chunk operations a rollout needs — allocate, slot mapping, commit, window +lookup, free. It lives in the model runner (worker / GPU side), co-located with +the model and the KV tensors; the DreamZero pipeline calls these methods during a +rollout. The main-process ``ARDiffusionEngine`` only selects the engine and is otherwise +thin. +""" + +from __future__ import annotations + +import inspect +import os +from collections.abc import Sequence + +import torch +from vllm.logger import init_logger +from vllm.v1.core.kv_cache_manager import KVCacheManager +from vllm.v1.kv_cache_interface import ( + KVCacheConfig, + KVCacheGroupSpec, + KVCacheSpec, + KVCacheTensor, +) +from vllm.v1.request import RequestStatus + +from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig +from vllm_omni.experimental.ar_diffusion.kv_cache.paged import ( + ChunkWindowSpec, + allocate_kv_pool_with_views, + chunk_slot_mapping, + pool_write_chunk, + resident_block_ids, +) + +_log = init_logger(__name__) + + +class ARDiffusionRequestAdapter: + """Duck-types the subset of ``vllm.v1.request.Request`` that the + ``KVCacheManager`` reads (``allocate_slots`` / ``get_computed_blocks`` / + ``free`` and the coordinator they call into). + + It is intentionally NOT a full ``Request``. The conformance test exercises a + real ``KVCacheManager`` against this adapter so the surface cannot silently + drift across vLLM versions. + + A AR-Diffusion request advances one *chunk* at a time: ``allocate_slots`` is called + once per chunk and ``num_computed_tokens`` advances only when a chunk is + committed (:meth:`on_chunk_committed`), so the ``T`` denoise steps of a chunk + reuse the same slots. + """ + + def __init__( + self, + request_id: str, + *, + chunk_size: int, + prefill_prefix_tokens: int = 0, + ) -> None: + self.request_id = request_id + self._chunk_size = chunk_size + self._prefill = prefill_prefix_tokens + self._completed_chunks = 0 + # Filled only when cross-request prefix reuse is enabled (Phase 3). + self.block_hashes: list = [] + self.skip_reading_prefix_cache = True + self.num_preemptions = 0 + # vLLM watermark gate reads this; map the request lifecycle onto it. + self.status = RequestStatus.WAITING + + @property + def num_computed_tokens(self) -> int: + """Persistent KV already materialized (committed chunks + prefill).""" + return self._prefill + self._completed_chunks * self._chunk_size + + @property + def num_tokens(self) -> int: + """Total tokens once the in-flight chunk is committed.""" + return self._prefill + (self._completed_chunks + 1) * self._chunk_size + + @property + def num_prompt_tokens(self) -> int: + """The prefill prefix length (read by ``cache_blocks`` when caching).""" + return self._prefill + + @property + def completed_chunks(self) -> int: + return self._completed_chunks + + def on_chunk_committed(self) -> None: + """Advance by one chunk. Call once per chunk, not per denoise step.""" + self._completed_chunks += 1 + + +def compute_num_blocks( + available_bytes: int, + gpu_memory_fraction: float, + page_size_bytes: int, +) -> int: + """Number of KV blocks that fit in ``fraction`` of the memory budget.""" + if page_size_bytes <= 0: + raise ValueError(f"page_size_bytes must be positive, got {page_size_bytes}") + if not 0.0 < gpu_memory_fraction <= 1.0: + raise ValueError(f"gpu_memory_fraction must be in (0, 1], got {gpu_memory_fraction}") + budget = int(available_bytes * gpu_memory_fraction) + return max(0, budget // page_size_bytes) + + +def build_kv_manager( + spec: KVCacheSpec, + layer_names: Sequence[str], + num_blocks: int, + max_model_len: int, + *, + enable_caching: bool = False, +) -> KVCacheManager: + """Build a ``KVCacheManager`` with a single KV cache group for ``spec``. + + Args: + spec: The KV cache spec for the group (e.g. a ``ChunkWindowSpec``). + layer_names: Attention layers sharing this group's block table. + num_blocks: Total physical blocks in the pool. + max_model_len: Upper bound on a request's sequence length. + enable_caching: Cross-request prefix caching (Phase 3); off in Phase 1. + """ + layer_names = list(layer_names) + group = KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec) + tensors = [KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=layer_names)] + config = KVCacheConfig( + num_blocks=num_blocks, + kv_cache_tensors=tensors, + kv_cache_groups=[group], + ) + kwargs = dict(max_model_len=max_model_len, hash_block_size=spec.block_size, enable_caching=enable_caching) + params = inspect.signature(KVCacheManager).parameters + if "scheduler_block_size" in params: + kwargs["scheduler_block_size"] = spec.block_size + if "max_num_batched_tokens" in params: + kwargs["max_num_batched_tokens"] = max_model_len + return KVCacheManager(config, **kwargs) + + +class ARDiffusionKVCache: + """Owns the paged KV pool + per-request lifecycle for a AR-Diffusion model. + + Build once per loaded model (dimensions known); then per request: + ``begin_request`` → per chunk (``allocate_chunk`` → ``chunk_write_slots`` → + [model writes K/V] → ``commit_chunk``) → ``end_request``. + """ + + def __init__( + self, + config: ARDiffusionKVConfig, + *, + num_layers: int, + num_kv_heads: int, + head_size: int, + dtype: torch.dtype, + block_size: int, + max_model_len: int, + available_bytes: int, + cross_attn_length: int = 0, + cross_attn_img_length: int = 0, + device: torch.device | None = None, + local_branches: int = 2, + num_frame_per_block: int = 1, + ) -> None: + if not config.enable: + raise ValueError("ARDiffusionKVCache built with a disabled ARDiffusionKVConfig") + if config.window_chunks is None: + raise ValueError("Phase 1 requires a bounded window (window_chunks)") + if config.chunk_size <= 0: + raise ValueError("ARDiffusionKVConfig.chunk_size must be set (> 0)") + if local_branches not in (1, 2): + raise ValueError(f"local_branches must be 1 or 2, got {local_branches}") + + self.config = config + # How many CFG branches allocate from THIS rank's pool. Under + # CFG-parallel x2 each rank executes exactly one branch (rank0 pos, + # rank1 neg) and the other branch's lazy contexts never allocate, so + # sizing for both would leave ~half the pool as idle capacity. A + # single-process run (TP1 / offline) executes both branches -> 2. + self.local_branches = local_branches + self.num_frame_per_block = max(1, int(num_frame_per_block)) + self.block_size = block_size + self.num_layers = num_layers + self.num_kv_heads = num_kv_heads + self.head_size = head_size + self.dtype = dtype + self.cross_attn_length = cross_attn_length + self.device = device or torch.device("cpu") + self._adapters: dict[str, ARDiffusionRequestAdapter] = {} + + # -- cross-attention pool (static, one-time-fill) ----------------------- + # Cross-attn KV is computed once from the text encoder and never changes. + # Allocate a per-layer contiguous tensor for each branch: (2, text_len, + # kv_heads, head_dim) where dim-0 = [pos, neg]. Allocated separately + # from the self-attn block pool (static, small — ~400 MiB for + # DreamZero I2V). Both pools draw from the same GPU free memory but + # the cross-attn pool is sized directly rather than via the block pool + # budget (no eviction/chunk lifecycle needed). + self.cross_attn_img_length = cross_attn_img_length + self._cross_k: list[torch.Tensor] = [] + self._cross_v: list[torch.Tensor] = [] + # I2V image-token cross-attn pool. Like the text k/v, the image-token + # k_img/v_img are session-invariant (the conditioning image doesn't change), + # so they are cached once and read every denoise step — see + # WanI2VCrossAttention (#4154 caches these model-side too). Empty for T2V. + self._cross_k_img: list[torch.Tensor] = [] + self._cross_v_img: list[torch.Tensor] = [] + + # Bytes the cross-attn pools consume directly (K+V, pos+neg, all layers). + # Deducted from the self-attn paged-pool budget below so the two + # allocations together stay within the GPU free-memory budget. + def _cross_pool_bytes(length: int) -> int: + return 2 * 2 * length * num_kv_heads * head_size * dtype.itemsize * num_layers + + cross_total_bytes = 0 + if device is not None and cross_attn_length > 0: + cross_shape = (2, cross_attn_length, num_kv_heads, head_size) + cross_bytes = _cross_pool_bytes(cross_attn_length) + cross_total_bytes += cross_bytes + for _ in range(num_layers): + self._cross_k.append(torch.empty(cross_shape, dtype=dtype, device=device)) + self._cross_v.append(torch.empty(cross_shape, dtype=dtype, device=device)) + _log.info( + "AR-Diffusion cross-attn pool: %d layers × (%d tok × %d heads × %d) = %.1f MiB", + num_layers, + cross_attn_length, + num_kv_heads, + head_size, + cross_bytes / (1024 * 1024), + ) + if cross_attn_img_length > 0: + img_shape = (2, cross_attn_img_length, num_kv_heads, head_size) + cross_total_bytes += _cross_pool_bytes(cross_attn_img_length) + for _ in range(num_layers): + self._cross_k_img.append(torch.empty(img_shape, dtype=dtype, device=device)) + self._cross_v_img.append(torch.empty(img_shape, dtype=dtype, device=device)) + _log.info( + "AR-Diffusion cross-attn IMG pool: %d layers × %d img-tok (I2V)", + num_layers, + cross_attn_img_length, + ) + + self.spec = ChunkWindowSpec( + block_size=block_size, + num_kv_heads=num_kv_heads, + head_size=head_size, + dtype=dtype, + sliding_window=config.window_chunks * config.chunk_size, + chunk_size=config.chunk_size, + window_chunks=config.window_chunks, + sink_chunks=config.sink_chunks, + reset_at_boundary=config.reset_at_boundary, + ) + # Each pool block spans all layers' K/V, so size against the per-layer + # page size times the layer count. + # Size the self-attn pool against the memory left after the cross-attn + # pools (allocated above), so cross + self-attn stays within the budget. + num_blocks = compute_num_blocks( + max(0, available_bytes - cross_total_bytes), + config.gpu_memory_fraction, + self.spec.page_size_bytes * num_layers, + ) + # Floor: one forward needs the resident window plus the in-flight chunk + # (num_frame_per_block frame-blocks) for every branch THIS rank runs, + # with a little eviction-transient headroom. The memory-fraction + # heuristic can under-size this once block_size grows — e.g. frame-granular + # paging at the true frame_seqlen makes each block larger and the pool + # fewer-blocks — so guarantee the minimum the rollout cannot run without, + # otherwise allocate_chunk hits an exhausted pool mid-forward. + min_blocks = self.local_branches * (config.window_chunks + self.num_frame_per_block) + 2 + if num_blocks < min_blocks: + _log.warning( + "AR-Diffusion KV pool: memory-fraction sizing gave %d blocks; raising to the %d-block " + "floor (%d local CFG branch(es) x (window_chunks=%d + num_frame_per_block=%d) + 2 headroom)", + num_blocks, + min_blocks, + self.local_branches, + config.window_chunks, + self.num_frame_per_block, + ) + num_blocks = min_blocks + layer_names = [f"ar_diffusion.layer.{i}" for i in range(num_layers)] + self.manager = build_kv_manager(self.spec, layer_names, num_blocks, max_model_len) + self.managed_num_blocks = num_blocks + self.num_blocks = num_blocks + # Scratch blocks are outside KVCacheManager ownership. They hold current + # denoise-step video KV when update_kv_cache=False and action/state KV in + # both modes; they are reused every forward and never committed. + # Default 4: measured high-water is num_frame_per_block video blocks (2) + # + 1 action block per branch per forward, +1 margin. + scratch_per_branch = int(os.environ.get("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", "4")) + if scratch_per_branch <= 0: + raise ValueError("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH must be positive") + self.scratch_blocks_per_branch = scratch_per_branch + self.scratch_num_blocks = self.local_branches * scratch_per_branch + self.num_blocks_total = self.managed_num_blocks + self.scratch_num_blocks + self.null_block_id = self.manager.block_pool.null_block.block_id + + # Allocate the per-layer paged K/V pools on the given device. + self._kv_pools: list[torch.Tensor] = [] + self._k_pools: list[torch.Tensor] = [] + self._v_pools: list[torch.Tensor] = [] + if device is not None: + self._kv_pools, self._k_pools, self._v_pools = allocate_kv_pool_with_views( + self.num_blocks_total, + block_size, + num_layers, + num_kv_heads, + head_size, + dtype, + device, + ) + + # -- cross-attention pool access ------------------------------------------- + # Cross-attn KV is static once populated — write once (from text encoder), + # read many (every denoising step). Not managed through the paged block pool. + + def write_cross_kv( + self, + layer_idx: int, + is_negative: bool, + k: torch.Tensor | None, + v: torch.Tensor | None, + k_img: torch.Tensor | None = None, + v_img: torch.Tensor | None = None, + ) -> None: + """Write one layer's cross-attn K/V into the pool. + + ``k`` / ``v``: ``(B, text_len, tp_num_heads, head_dim)``; only batch-0 + is copied (B=1 for inference). ``None`` skips the text half (window + restarts keep the still-valid text K/V and rewrite only the image half). + The ``is_negative`` flag selects the correct CFG branch slot. ``k_img`` / + ``v_img`` (I2V image tokens, ``(B, 257, ...)``) are written when the + image pool is allocated. + """ + branch = 1 if is_negative else 0 + if k is not None: + self._cross_k[layer_idx][branch].copy_(k[0]) + self._cross_v[layer_idx][branch].copy_(v[0]) + if k_img is not None and self._cross_k_img: + self._cross_k_img[layer_idx][branch].copy_(k_img[0]) + self._cross_v_img[layer_idx][branch].copy_(v_img[0]) + + def read_cross_kv(self, layer_idx: int, is_negative: bool) -> dict: + """Return a pool-backed cross-attn cache dict for one layer. + + The dict matches the ``{"is_init": True, "k": Tensor, "v": Tensor}`` + convention the cross-attention module expects — it reads from the pool + slice rather than from the lazy-initialised model-local dict. For I2V, + ``k_img`` / ``v_img`` are added so the image-token cache (added by #4154) + reads from the pool too. + """ + branch = 1 if is_negative else 0 + k = self._cross_k[layer_idx][branch].unsqueeze(0) # (1, L, heads, dim) + v = self._cross_v[layer_idx][branch].unsqueeze(0) + cache = {"is_init": True, "k": k, "v": v} + if self._cross_k_img: + cache["k_img"] = self._cross_k_img[layer_idx][branch].unsqueeze(0) + cache["v_img"] = self._cross_v_img[layer_idx][branch].unsqueeze(0) + return cache + + # -- request lifecycle --------------------------------------------------- + + def begin_request(self, request_id: str, *, prefill_prefix_tokens: int = 0) -> ARDiffusionRequestAdapter: + adapter = ARDiffusionRequestAdapter( + request_id, + chunk_size=self.spec.chunk_size, + prefill_prefix_tokens=prefill_prefix_tokens, + ) + self._adapters[request_id] = adapter + _log.debug("AR-Diffusion begin_request: req=%s prefill=%d", request_id, prefill_prefix_tokens) + return adapter + + def end_request(self, adapter: ARDiffusionRequestAdapter) -> None: + _log.debug( + "AR-Diffusion end_request: req=%s chunks=%d free=%d", + adapter.request_id, + adapter.completed_chunks, + self.manager.block_pool.get_num_free_blocks(), + ) + self.manager.free(adapter) + self._adapters.pop(adapter.request_id, None) + + # -- per-chunk operations ------------------------------------------------ + + def allocate_chunk(self, adapter: ARDiffusionRequestAdapter) -> list[int]: + """Allocate a chunk's blocks (evicting out-of-window blocks first). + + Returns the request's full block table (incl. null_block placeholders). + """ + blocks = self.manager.allocate_slots(adapter, num_new_tokens=self.spec.chunk_size) + if blocks is None: + raise RuntimeError("AR-Diffusion KV pool exhausted while allocating a chunk") + table = self.block_table(adapter) + resident = resident_block_ids(table, self.null_block_id) + _log.debug( + "AR-Diffusion allocate_chunk: req=%s chunk=%d table_len=%d resident=%d free=%d", + adapter.request_id, + adapter.completed_chunks, + len(table), + len(resident), + self.manager.block_pool.get_num_free_blocks(), + ) + return table + + def allocate_token_slots(self, adapter: ARDiffusionRequestAdapter, num_tokens: int) -> list[int]: + """Allocate managed blocks for an in-flight video span without committing it.""" + if num_tokens <= 0: + raise ValueError(f"num_tokens must be positive, got {num_tokens}") + blocks = self.manager.allocate_slots(adapter, num_new_tokens=num_tokens) + if blocks is None: + raise RuntimeError("AR-Diffusion KV pool exhausted while allocating paged attention slots") + return self.block_table(adapter) + + def block_table(self, adapter: ARDiffusionRequestAdapter) -> list[int]: + return list(self.manager.get_block_ids(adapter.request_id)[0]) + + def chunk_write_slots(self, adapter: ARDiffusionRequestAdapter) -> torch.Tensor: + """Slot mapping for the in-flight chunk — the K/V write target.""" + return chunk_slot_mapping( + self.block_table(adapter), + adapter.num_computed_tokens, + self.spec.chunk_size, + self.block_size, + ) + + def scratch_block_ids(self, is_negative: bool, start: int, count: int) -> list[int]: + """Return branch-local scratch block ids outside manager ownership.""" + if count < 0 or start < 0: + raise ValueError(f"scratch start/count must be non-negative, got start={start}, count={count}") + if start + count > self.scratch_blocks_per_branch: + raise RuntimeError( + "AR-Diffusion paged attention scratch blocks exhausted: " + f"need [{start}, {start + count}) of {self.scratch_blocks_per_branch}. " + "Increase AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH." + ) + # With one local branch (CFG-parallel x2) the rank's only branch maps to + # slot 0 whichever CFG side it is; the other branch never allocates here. + branch_offset = self.scratch_blocks_per_branch if (is_negative and self.local_branches == 2) else 0 + base = self.managed_num_blocks + branch_offset + start + return list(range(base, base + count)) + + def key_cache(self, layer_idx: int) -> torch.Tensor: + return self._kv_pools[layer_idx][0] + + def value_cache(self, layer_idx: int) -> torch.Tensor: + return self._kv_pools[layer_idx][1] + + def window_block_ids(self, adapter: ARDiffusionRequestAdapter) -> list[int]: + """Resident (non-null) managed blocks visible to paged attention.""" + return resident_block_ids(self.block_table(adapter), self.null_block_id) + + def commit_chunk(self, adapter: ARDiffusionRequestAdapter) -> None: + """Advance the adapter by one chunk after its K/V is written. + + This standalone primitive is used by low-level manager tests. In the + DreamZero paged-attention path, :meth:`ARDiffusionKVState.commit_paged_context` + advances the adapter only after the forward succeeds. Call once per + committed chunk, not per denoise step. + """ + _log.debug("AR-Diffusion commit: req=%s before=%d", adapter.request_id, adapter.completed_chunks) + adapter.on_chunk_committed() + _log.debug("AR-Diffusion commit: req=%s after=%d", adapter.request_id, adapter.completed_chunks) + + # -- pool-backed K/V access -------------------------------------------- + + def write_chunk_kv( + self, + layer_index: int, + new_k: torch.Tensor, + new_v: torch.Tensor, + adapter: ARDiffusionRequestAdapter, + ) -> None: + """Write one layer's committed-chunk K/V into the pool.""" + slots = self.chunk_write_slots(adapter) + _log.debug( + "AR-Diffusion write: req=%s layer=%d chunk=%d shapes=%s dev=%s", + adapter.request_id, + layer_index, + adapter.completed_chunks, + (tuple(new_k.shape), tuple(new_v.shape)), + slots.device, + ) + pool_write_chunk( + self._k_pools[layer_index], + self._v_pools[layer_index], + new_k, + new_v, + slots, + ) diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py new file mode 100644 index 000000000..cb84467a3 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Generic paging mechanics + chunk-window eviction spec for the AR-Diffusion engine. + +Engine-generic, model-agnostic primitives — the layer a second model (e.g. the +Cosmos port) reuses unchanged. Three concerns live here: + +* **Slot mapping** — absolute token positions → physical KV-cache slots, the + standard PagedAttention layout ``slot(pos) = block_id(pos) * block_size + + (pos % block_size)``. +* **Pool I/O** — allocate per-layer FlashAttention-compatible paged K/V pools + plus flat compatibility views for direct slot writes. +* **Chunk-window eviction** — a ``SlidingWindowSpec`` subclass whose unit is a + *chunk* (``sliding_window = window_chunks * chunk_size``) plus a manager that + evicts at chunk boundaries. Memory policy / refcounting / ``null_block`` + replacement stay in vLLM's ``BlockPool``; only the token-skip math is here. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import torch +from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager +from vllm.v1.kv_cache_interface import SlidingWindowSpec + +try: + from vllm.v1.kv_cache_spec_registry import register_kv_cache_spec +except ModuleNotFoundError: + + def register_kv_cache_spec(*, manager_class, uniform_type_base_spec=None): + """Compatibility shim for vLLM versions without kv_cache_spec_registry.""" + + def decorator(spec_class): + from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map + + spec_manager_map[spec_class] = manager_class + return spec_class + + return decorator + + +# ── Slot mapping ──────────────────────────────────────────────────────────── + + +def compute_slot_mapping( + block_ids: Sequence[int], + positions: torch.Tensor | Sequence[int], + block_size: int, +) -> torch.Tensor: + """Map absolute token positions to physical KV-cache slots. + + Args: + block_ids: physical block id per block index (the request's block table). + positions: absolute token positions to map (1-D). + block_size: tokens per block. + + Returns: + ``LongTensor`` of physical slots, one per position. + """ + if block_size <= 0: + raise ValueError(f"block_size must be positive, got {block_size}") + table = torch.as_tensor(block_ids, dtype=torch.long) + pos = torch.as_tensor(positions, dtype=torch.long) + block_index = torch.div(pos, block_size, rounding_mode="floor") + offset = pos % block_size + return table[block_index] * block_size + offset + + +def chunk_slot_mapping( + block_ids: Sequence[int], + num_computed_tokens: int, + chunk_size: int, + block_size: int, +) -> torch.Tensor: + """Slot mapping for the in-flight chunk's tokens (the commit write target). + + The chunk occupies absolute positions + ``[num_computed_tokens, num_computed_tokens + chunk_size)``. + """ + positions = torch.arange( + num_computed_tokens, + num_computed_tokens + chunk_size, + dtype=torch.long, + ) + return compute_slot_mapping(block_ids, positions, block_size) + + +def resident_block_ids(block_ids: Sequence[int], null_block_id: int) -> list[int]: + """Real (non-null) blocks currently resident, in table order. + + These are the blocks the read path gathers the attention window from; + out-of-window positions are the shared ``null_block`` and are excluded. + """ + return [int(b) for b in block_ids if int(b) != null_block_id] + + +# ── Pool I/O (allocate / write) ───────────────────────────────────────────── + + +def allocate_kv_pool_with_views( + num_blocks: int, + block_size: int, + num_layers: int, + num_kv_heads: int, + head_dim: int, + dtype: torch.dtype, + device: torch.device, +) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]: + """Allocate vLLM-style paged KV pools plus flat slot-write views. + + The owning tensor follows FlashAttention's block-table cache layout: + ``(2, num_blocks, block_size, num_kv_heads, head_dim)``, where dim-0 is + ``[K, V]``. The flat K/V views keep ``slot = block_id * block_size + + offset`` writes simple without changing the kernel-facing cache layout. + """ + kv_pools: list[torch.Tensor] = [] + k_pools: list[torch.Tensor] = [] + v_pools: list[torch.Tensor] = [] + for _ in range(num_layers): + kv = torch.empty(2, num_blocks, block_size, num_kv_heads, head_dim, dtype=dtype, device=device) + kv_pools.append(kv) + k_pools.append(kv[0].reshape(num_blocks * block_size, num_kv_heads, head_dim)) + v_pools.append(kv[1].reshape(num_blocks * block_size, num_kv_heads, head_dim)) + return kv_pools, k_pools, v_pools + + +def pool_write_chunk( + k_pool: torch.Tensor, + v_pool: torch.Tensor, + new_k: torch.Tensor, + new_v: torch.Tensor, + slot_mapping: torch.Tensor, +) -> None: + """Write the committed chunk's K/V into pool slots (in place). + + ``new_k`` / ``new_v`` shape: ``(batch, chunk_size, num_kv_heads, head_dim)``. + ``slot_mapping`` shape: ``(chunk_size,)`` — one physical slot per token position. + The batch dim is written to the same slots (multi-batch writes the same + positions; the caller is responsible for per-sequence bookkeeping). + + This is the *commit* write — called once per chunk, after the last denoise step. + """ + k_pool[slot_mapping] = new_k[0] # batch index 0 + v_pool[slot_mapping] = new_v[0] + + +def _drop_batch(t: torch.Tensor) -> torch.Tensor: + """``(1, L, n_heads, head_dim)`` -> ``(L, n_heads, head_dim)``; pass 3-D through.""" + if t.dim() == 4 and t.shape[0] == 1: + return t[0] + return t + + +# ── Chunk-window eviction (spec + manager) ────────────────────────────────── + + +def chunk_window_skipped_tokens( + num_computed_tokens: int, + *, + chunk_size: int, + sliding_window: int, + sink_chunks: int, + reset_at_boundary: bool, +) -> int: + """Tokens outside the resident chunk window, snapped to a chunk boundary. + + Pure function so the eviction policy is unit-testable without constructing a + manager. Two strategies: + + - ``reset_at_boundary`` (DreamZero): at each chunk boundary everything past + the sink is dropped. + - otherwise (VGGT-style sliding replace): keep the last ``window`` tokens + (plus the sink); the skip count snaps down to a chunk boundary so a chunk + is never half-evicted. + """ + sink = sink_chunks * chunk_size + if reset_at_boundary: + completed = (num_computed_tokens // chunk_size) * chunk_size + return max(0, completed - sink) + skipped = max(0, num_computed_tokens - sliding_window - sink) + return (skipped // chunk_size) * chunk_size + + +class ChunkWindowManager(SlidingWindowManager): + """``SlidingWindowManager`` that evicts at chunk boundaries. + + ``self.sliding_window`` is set by the base ``__init__``; the chunk fields are + read from ``self.kv_cache_spec`` (a :class:`ChunkWindowSpec`). + """ + + def get_num_skipped_tokens(self, num_computed_tokens: int) -> int: + spec = self.kv_cache_spec + return chunk_window_skipped_tokens( + num_computed_tokens, + chunk_size=spec.chunk_size, + sliding_window=self.sliding_window, + sink_chunks=spec.sink_chunks, + reset_at_boundary=spec.reset_at_boundary, + ) + + +# Register so KVCacheManager resolves ChunkWindowSpec to ChunkWindowManager. +# Dispatch walks the spec's MRO, so without explicit registration the subclass +# would silently fall back to the parent SlidingWindowManager (override ignored). +# uniform_type_base_spec=None => its own KV cache group. +@register_kv_cache_spec(manager_class=ChunkWindowManager, uniform_type_base_spec=None) +@dataclass(frozen=True, kw_only=True) +class ChunkWindowSpec(SlidingWindowSpec): + # sliding_window (inherited) MUST equal window_chunks * chunk_size. + chunk_size: int + window_chunks: int + sink_chunks: int = 0 + reset_at_boundary: bool = False + + def __post_init__(self): + super().__post_init__() + if self.sliding_window != self.window_chunks * self.chunk_size: + raise ValueError( + "ChunkWindowSpec.sliding_window must equal " + f"window_chunks * chunk_size ({self.window_chunks} * " + f"{self.chunk_size}), got {self.sliding_window}" + ) diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/paged_attention.py b/vllm_omni/experimental/ar_diffusion/kv_cache/paged_attention.py new file mode 100644 index 000000000..2038e496d --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/paged_attention.py @@ -0,0 +1,550 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Paged self-attention helpers for AR-Diffusion DreamZero KV reuse.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, NamedTuple + +import torch + +from vllm_omni.experimental.ar_diffusion.kv_cache.paged import compute_slot_mapping + +# Set by ARDiffusionPagedForwardContext.prepare() before each branch forward and +# read by the fused write+attend custom op below. The pools are process-lifetime +# allocations owned by one ARDiffusionKVCache per worker process (CFG-parallel / +# TP ranks are separate processes), mirroring vLLM's forward-context pattern for +# keeping multi-GiB cache tensors out of the compiled graph's inputs. +_CURRENT_PAGED_KV_CACHE: Any = None + + +def set_current_paged_kv_cache(kv_cache: Any) -> None: + global _CURRENT_PAGED_KV_CACHE + _CURRENT_PAGED_KV_CACHE = kv_cache + + +_LAYER_IDX_TENSORS: dict[int, torch.Tensor] = {} + + +def _layer_idx_tensor(layer_idx: int) -> torch.Tensor: + t = _LAYER_IDX_TENSORS.get(layer_idx) + if t is None: + t = torch.tensor(layer_idx, dtype=torch.int64) + _LAYER_IDX_TENSORS[layer_idx] = t + return t + + +class ARDiffusionPagedLayerInputs(NamedTuple): + """Compiled-region payload for one layer's paged self-attention. + + A NamedTuple of plain tensors + ints so ``torch.compile`` treats every field + as a pytree graph input (no object-attribute guards, no recompiles when only + tensor *values* change). All layers of one branch forward share the same + metadata tensor objects, built once by ``prepare()``. + + ``layer_idx`` is a 0-dim CPU tensor, NOT a python int: all 40 DiT blocks + share one compiled code object, and an int here becomes a per-layer dynamo + value guard (``layer_idx == k``) — 40 cache variants that blow the + recompile limit. A tensor input guards on shape/dtype only, so one graph + serves every layer. + """ + + layer_idx: torch.Tensor + seq_len: int + video_slots: torch.Tensor + action_slots: torch.Tensor + block_table: torch.Tensor + query_start_loc: torch.Tensor + seq_lens: torch.Tensor + max_query_len: int + max_seq_len: int + + +@dataclass +class ARDiffusionPagedForwardContext: + """Mutable branch-level state shared by all layer contexts in one forward.""" + + kv_cache: Any + adapter: Any + is_negative: bool + history_block_ids: list[int] + seq_len: int + commit_current: bool + max_video_tokens: int + current_video_block_ids: list[int] = field(default_factory=list) + current_video_slot_mapping: torch.Tensor | None = None + action_scratch_block_ids: list[int] = field(default_factory=list) + action_slot_mapping: torch.Tensor | None = None + query_len: int = 0 + kv_len: int = 0 + _allocated_video: bool = False + _committed: bool = False + _action_len: int = 0 + # Set once by prepare(); shared by all layers of the branch forward. + block_table: torch.Tensor | None = None + query_start_loc: torch.Tensor | None = None + seq_lens: torch.Tensor | None = None + max_query_len: int = 0 + max_seq_len: int = 0 + _prepared: bool = False + + @property + def block_size(self) -> int: + return int(self.kv_cache.block_size) + + @property + def num_current_video_blocks(self) -> int: + if self.seq_len % self.block_size != 0: + raise AssertionError( + "AR-Diffusion paged attention expects frame-aligned seq_len " + f"(multiple of block_size={self.block_size}), got {self.seq_len}" + ) + return self.seq_len // self.block_size + + def ensure_video_slots(self, device: torch.device) -> None: + """Allocate/write targets for the current video tokens, once per branch.""" + if self._allocated_video: + return + + n_blocks = self.num_current_video_blocks + if self.commit_current: + start = int(self.adapter.num_computed_tokens) + self.kv_cache.allocate_token_slots(self.adapter, self.seq_len) + table = self.kv_cache.block_table(self.adapter) + start_block = start // self.block_size + self.current_video_block_ids = [int(b) for b in table[start_block : start_block + n_blocks]] + positions = torch.arange(start, start + self.seq_len, dtype=torch.long) + self.current_video_slot_mapping = compute_slot_mapping(table, positions, self.block_size).to(device=device) + else: + self.current_video_block_ids = self.kv_cache.scratch_block_ids(self.is_negative, 0, n_blocks) + positions = torch.arange(self.seq_len, dtype=torch.long) + self.current_video_slot_mapping = compute_slot_mapping( + self.current_video_block_ids, + positions, + self.block_size, + ).to(device=device) + self._allocated_video = True + + def ensure_action_slots(self, action_len: int, device: torch.device) -> None: + """Reserve scratch slots for action/state K/V, if present.""" + if action_len <= 0: + self.action_scratch_block_ids = [] + self.action_slot_mapping = torch.empty(0, dtype=torch.long, device=device) + self._action_len = 0 + return + + self.ensure_video_slots(device) + if self.action_slot_mapping is not None and self._action_len == action_len: + return + + action_blocks = (action_len + self.block_size - 1) // self.block_size + scratch_offset = 0 if self.commit_current else len(self.current_video_block_ids) + self.action_scratch_block_ids = self.kv_cache.scratch_block_ids( + self.is_negative, + scratch_offset, + action_blocks, + ) + positions = torch.arange(action_len, dtype=torch.long) + self.action_slot_mapping = compute_slot_mapping( + self.action_scratch_block_ids, + positions, + self.block_size, + ).to(device=device) + self._action_len = action_len + + def video_block_table(self, device: torch.device) -> tuple[list[int], int]: + self.ensure_video_slots(device) + if self.max_video_tokens % self.block_size != 0: + raise AssertionError( + "AR-Diffusion paged attention requires max_video_tokens to be block-aligned, " + f"got max_video_tokens={self.max_video_tokens}, block_size={self.block_size}" + ) + all_video_blocks = self.history_block_ids + self.current_video_block_ids + max_video_blocks = self.max_video_tokens // self.block_size + visible_video_blocks = all_video_blocks[-max_video_blocks:] if max_video_blocks else [] + video_len = min(len(all_video_blocks) * self.block_size, self.max_video_tokens) + return visible_video_blocks, video_len + + def build_block_table( + self, + *, + action_len: int, + query_len: int, + device: torch.device, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]: + """Build FlashAttention block-table metadata for one self-attn call. + + The block table is tail-padded to a fixed width and ``max_seq_len`` is a + constant upper bound, so across window growth only tensor *values* change + — tensor shapes and the int consts stay stable for ``torch.compile`` + (and, later, CUDA-graph capture). The kernel only dereferences the first + ``ceil(seq_lens/block_size)`` entries, so padding is never read. + """ + video_blocks, video_len = self.video_block_table(device) + self.ensure_action_slots(action_len, device) + action_blocks = self.action_scratch_block_ids if action_len > 0 else [] + block_ids = video_blocks + action_blocks + if not block_ids: + raise RuntimeError("AR-Diffusion paged attention needs at least current video KV blocks") + + # Fixed capacity: full visible video window + one action-capacity block. + action_capacity_blocks = max(1, (action_len + self.block_size - 1) // self.block_size) + width = max(self.max_video_tokens // self.block_size + action_capacity_blocks, len(block_ids)) + padded = block_ids + [0] * (width - len(block_ids)) + + self.query_len = int(query_len) + self.kv_len = int(video_len + action_len) + max_seq_len = int(self.max_video_tokens + action_capacity_blocks * self.block_size) + block_table = torch.tensor([padded], dtype=torch.int32, device=device) + query_start_loc = torch.tensor([0, self.query_len], dtype=torch.int32, device=device) + seq_lens = torch.tensor([self.kv_len], dtype=torch.int32, device=device) + return block_table, query_start_loc, seq_lens, self.query_len, max_seq_len + + def prepare(self, device: torch.device, action_len: int, query_len: int) -> None: + """Host-side, once-per-branch-forward setup (called OUTSIDE torch.compile). + + Allocates the current video/action slots (still lazy: only the branch a + CFG-parallel rank actually runs reaches its ``_forward_blocks``), builds + the padded block-table metadata ONCE for all layers, and publishes the + pool registry for the fused custom op. The compiled per-layer code then + only consumes prebuilt tensors via ``ARDiffusionPagedLayerInputs``. + """ + if getattr(self, "_prepared", False): + return + self.ensure_video_slots(device) + ( + self.block_table, + self.query_start_loc, + self.seq_lens, + self.max_query_len, + self.max_seq_len, + ) = self.build_block_table(action_len=action_len, query_len=query_len, device=device) + if self.action_slot_mapping is None: + self.action_slot_mapping = torch.empty(0, dtype=torch.long, device=device) + set_current_paged_kv_cache(self.kv_cache) + self._prepared = True + + def layer_inputs(self, layer_idx: int) -> ARDiffusionPagedLayerInputs: + if not getattr(self, "_prepared", False): + raise RuntimeError("ARDiffusionPagedForwardContext.layer_inputs() before prepare()") + return ARDiffusionPagedLayerInputs( + layer_idx=_layer_idx_tensor(layer_idx), + seq_len=int(self.seq_len), + video_slots=self.current_video_slot_mapping, + action_slots=self.action_slot_mapping, + block_table=self.block_table, + query_start_loc=self.query_start_loc, + seq_lens=self.seq_lens, + max_query_len=int(self.max_query_len), + max_seq_len=int(self.max_seq_len), + ) + + def mark_committed(self) -> None: + self._committed = True + + +@dataclass +class ARDiffusionPagedLayerContext: + """Layer-specific handle passed through DreamZero's existing ``kv_cache`` slot.""" + + is_ar_diffusion_paged_context: ClassVar[bool] = True + layer_idx: int + forward_ctx: ARDiffusionPagedForwardContext + + @property + def kv_cache(self): + return self.forward_ctx.kv_cache + + @property + def adapter(self): + return self.forward_ctx.adapter + + @property + def is_negative(self) -> bool: + return self.forward_ctx.is_negative + + @property + def history_block_ids(self) -> list[int]: + return self.forward_ctx.history_block_ids + + @property + def current_video_block_ids(self) -> list[int]: + return self.forward_ctx.current_video_block_ids + + @property + def current_video_slot_mapping(self) -> torch.Tensor | None: + return self.forward_ctx.current_video_slot_mapping + + @property + def action_scratch_block_ids(self) -> list[int]: + return self.forward_ctx.action_scratch_block_ids + + @property + def action_slot_mapping(self) -> torch.Tensor | None: + return self.forward_ctx.action_slot_mapping + + @property + def seq_len(self) -> int: + return self.forward_ctx.seq_len + + @property + def query_len(self) -> int: + return self.forward_ctx.query_len + + @property + def kv_len(self) -> int: + return self.forward_ctx.kv_len + + @property + def commit_current(self) -> bool: + return self.forward_ctx.commit_current + + def to_layer_inputs(self) -> ARDiffusionPagedLayerInputs: + """Compiled-region payload; requires ``forward_ctx.prepare()`` first.""" + return self.forward_ctx.layer_inputs(self.layer_idx) + + +def is_ar_diffusion_paged_context(value: object) -> bool: + return isinstance(value, ARDiffusionPagedLayerContext) + + +def _reference_paged_attention( + query: torch.Tensor, + key_cache: torch.Tensor, + value_cache: torch.Tensor, + block_table: torch.Tensor, + query_start_loc: torch.Tensor, + seq_lens: torch.Tensor, + softmax_scale: float, + *, + causal: bool, +) -> torch.Tensor: + if causal: + raise NotImplementedError("AR-Diffusion paged self-attention uses causal=False") + outs: list[torch.Tensor] = [] + block_size = key_cache.shape[1] + for i in range(seq_lens.shape[0]): + q_start = int(query_start_loc[i].item()) + q_end = int(query_start_loc[i + 1].item()) + kv_len = int(seq_lens[i].item()) + q = query[q_start:q_end] + positions = torch.arange(kv_len, device=query.device) + logical_blocks = torch.div(positions, block_size, rounding_mode="floor") + offsets = positions % block_size + physical_blocks = block_table[i, logical_blocks].long() + k = key_cache[physical_blocks, offsets] + v = value_cache[physical_blocks, offsets] + scores = torch.einsum("qhd,khd->hqk", q.float(), k.float()) * float(softmax_scale) + probs = torch.softmax(scores, dim=-1).to(v.dtype) + outs.append(torch.einsum("hqk,khd->qhd", probs, v)) + return torch.cat(outs, dim=0) + + +_FA_VERSION_BY_HEAD_SIZE: dict[int, int] = {} + + +def _resolve_fa_version(head_size: int) -> int: + # get_flash_attn_version -> current_platform.get_device_capability() is not + # dynamo-traceable, and the answer is fixed per head size for the process. + version = _FA_VERSION_BY_HEAD_SIZE.get(head_size) + if version is None: + try: + from vllm.v1.attention.backends.fa_utils import get_flash_attn_version + + version = int(get_flash_attn_version(requires_alibi=False, head_size=head_size) or 2) + except Exception: + version = 2 + _FA_VERSION_BY_HEAD_SIZE[head_size] = version + return version + + +def ar_diffusion_paged_attention( + query: torch.Tensor, + key_cache: torch.Tensor, + value_cache: torch.Tensor, + *, + block_table: torch.Tensor, + query_start_loc: torch.Tensor, + seq_lens: torch.Tensor, + max_query_len: int, + max_seq_len: int, + softmax_scale: float, + causal: bool = False, +) -> torch.Tensor: + """Run non-causal paged attention over a vLLM block table. + + ``query`` may be ``(B, L, H, D)`` or already flattened as ``(T, H, D)``. + ``key_cache`` / ``value_cache`` are ``(num_blocks, block_size, H, D)``. + """ + batched = query.dim() == 4 + if batched: + batch, q_len = query.shape[:2] + query_flat = query.reshape(batch * q_len, *query.shape[2:]) + else: + query_flat = query + + if not query_flat.is_cuda: + out = _reference_paged_attention( + query_flat, + key_cache, + value_cache, + block_table, + query_start_loc, + seq_lens, + softmax_scale, + causal=causal, + ) + else: + from vllm.vllm_flash_attn import flash_attn_varlen_func + + fa_version = _resolve_fa_version(query_flat.shape[-1]) + + out = torch.empty_like(query_flat) + flash_attn_varlen_func( + q=query_flat, + k=key_cache, + v=value_cache, + out=out, + cu_seqlens_q=query_start_loc, + max_seqlen_q=int(max_query_len), + seqused_k=seq_lens, + max_seqlen_k=int(max_seq_len), + softmax_scale=float(softmax_scale), + causal=causal, + block_table=block_table, + fa_version=fa_version, + ) + + if batched: + return out.reshape(query.shape) + return out + + +# ── Fused write+attend custom op (torch.compile-safe) ────────────────────── +# +# One opaque op per layer keeps the compiled DiT block fullgraph: dynamo treats +# it as a single graph node (no eager island, no graph breaks), and the K/V slot +# writes happen inside the op so write→read ordering with the block-table kernel +# is internal — no hidden-mutation ordering hazards against a separate reader op +# and no multi-GiB pool tensors as graph inputs (the flat and paged pool views +# alias the same storage, which AOTAutograd handles poorly as inputs). Pattern +# follows sage_attn3.py in-repo and vLLM's own unified attention ops. +def _paged_write_attn_impl( + query: torch.Tensor, + k_curr: torch.Tensor, + v_curr: torch.Tensor, + k_act: torch.Tensor | None, + v_act: torch.Tensor | None, + layer_idx: torch.Tensor, + video_slots: torch.Tensor, + action_slots: torch.Tensor, + block_table: torch.Tensor, + query_start_loc: torch.Tensor, + seq_lens: torch.Tensor, + max_query_len: int, + max_seq_len: int, + softmax_scale: float, +) -> torch.Tensor: + kv = _CURRENT_PAGED_KV_CACHE + if kv is None: + raise RuntimeError("ar_diffusion_paged_write_attn called before prepare() set the KV pool registry") + layer_idx = int(layer_idx) + k_pool = kv._k_pools[layer_idx] + v_pool = kv._v_pools[layer_idx] + k_pool[video_slots] = k_curr.to(k_pool.dtype) + v_pool[video_slots] = v_curr.to(v_pool.dtype) + if k_act is not None and v_act is not None and k_act.shape[0] > 0: + k_pool[action_slots] = k_act.to(k_pool.dtype) + v_pool[action_slots] = v_act.to(v_pool.dtype) + return ar_diffusion_paged_attention( + query, + kv.key_cache(layer_idx), + kv.value_cache(layer_idx), + block_table=block_table, + query_start_loc=query_start_loc, + seq_lens=seq_lens, + max_query_len=max_query_len, + max_seq_len=max_seq_len, + softmax_scale=softmax_scale, + causal=False, + ) + + +# hasattr guard keeps registration idempotent across test re-imports that pop +# the module from sys.modules (same as sage_attn3.py). +if not hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn"): + + @torch.library.custom_op("vllm_omni::ar_diffusion_paged_write_attn", mutates_args=()) + def _paged_write_attn_op( + query: torch.Tensor, + k_curr: torch.Tensor, + v_curr: torch.Tensor, + k_act: torch.Tensor | None, + v_act: torch.Tensor | None, + layer_idx: torch.Tensor, + video_slots: torch.Tensor, + action_slots: torch.Tensor, + block_table: torch.Tensor, + query_start_loc: torch.Tensor, + seq_lens: torch.Tensor, + max_query_len: int, + max_seq_len: int, + softmax_scale: float, + ) -> torch.Tensor: + return _paged_write_attn_impl( + query, + k_curr, + v_curr, + k_act, + v_act, + layer_idx, + video_slots, + action_slots, + block_table, + query_start_loc, + seq_lens, + max_query_len, + max_seq_len, + softmax_scale, + ) + + @_paged_write_attn_op.register_fake + def _( + query, + k_curr, + v_curr, + k_act, + v_act, + layer_idx, + video_slots, + action_slots, + block_table, + query_start_loc, + seq_lens, + max_query_len, + max_seq_len, + softmax_scale, + ): + return torch.empty_like(query) + + +def paged_write_attn( + inputs: ARDiffusionPagedLayerInputs, query, k_curr, v_curr, k_act, v_act, softmax_scale: float +) -> torch.Tensor: + """Model-facing entry: routes through the custom op (traceable in fullgraph).""" + return torch.ops.vllm_omni.ar_diffusion_paged_write_attn( + query, + k_curr, + v_curr, + k_act, + v_act, + inputs.layer_idx, + inputs.video_slots, + inputs.action_slots, + inputs.block_table, + inputs.query_start_loc, + inputs.seq_lens, + inputs.max_query_len, + inputs.max_seq_len, + softmax_scale, + ) diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/state.py b/vllm_omni/experimental/ar_diffusion/kv_cache/state.py new file mode 100644 index 000000000..510bbe043 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/state.py @@ -0,0 +1,183 @@ +# SPDX-License-Identifier: Apache-2.0 +"""DreamZero-facing AR-Diffusion KV bridge backed by paged self-attention.""" + +from __future__ import annotations + +from vllm.logger import init_logger + +from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import ( + ARDiffusionPagedForwardContext, + ARDiffusionPagedLayerContext, +) + +_log = init_logger(__name__) + + +class ARDiffusionKVState: + """Per-request bridge between a DreamZero rollout and the AR-Diffusion KV pool. + + The self-attention path is paged-only: ``get_kv_caches`` returns lightweight + per-layer contexts, and each attention layer writes current K/V into the + pool before calling the FlashAttention block-table kernel. No resident + window is gathered into a contiguous tensor. + """ + + def __init__(self, kv_cache, pos_adapter, neg_adapter, num_layers: int) -> None: + """One state per DreamZero session, holding BOTH CFG branches' adapters. + + ``pos``/``neg`` are the classifier-free-guidance branches: ``pos`` is + the conditional pass (real prompt), ``neg`` the unconditional pass + (negative prompt). Each owns an independent request adapter — and + therefore independent pool blocks — because the two branches produce + different K/V for the same frames. Every ``is_negative`` flag in this + module selects between them (``False`` -> ``pos``, ``True`` -> ``neg``). + """ + self.kv_cache = kv_cache + self.pos = pos_adapter + self.neg = neg_adapter + self.num_layers = num_layers + self._committed: dict[bool, int] = {False: 0, True: 0} + self._paged_pending: dict[bool, ARDiffusionPagedForwardContext | None] = {False: None, True: None} + # Cross-attn KV is populated once after text encoding (eager), then + # read every denoising step. Tracked per half: the text K/V depend only + # on the session prompt (survive window-boundary resets), while the I2V + # image-token K/V come from the current observation's CLIP features and + # must be re-projected on every window restart. + self._cross_text_populated: dict[bool, bool] = {False: False, True: False} + self._cross_img_populated: dict[bool, bool] = {False: False, True: False} + + def _adapter(self, is_negative: bool): + return self.neg if is_negative else self.pos + + def get_kv_caches( + self, + is_negative: bool, + seq_len: int | None = None, + commit_current: bool = False, + ) -> list[ARDiffusionPagedLayerContext]: + if seq_len is None: + raise ValueError("AR-Diffusion paged self-attention requires seq_len in get_kv_caches()") + return self.prepare_paged_context(is_negative, seq_len, commit_current) + + def prepare_paged_context( + self, + is_negative: bool, + seq_len: int, + commit_current: bool, + ) -> list[ARDiffusionPagedLayerContext]: + """Return per-layer paged attention contexts for one branch forward. + + Allocation is lazy: constructing both CFG branches' kwargs must not + allocate manager blocks for a branch that a CFG-parallel rank will not run. + The first self-attn layer that consumes the context allocates the current + video slots, then all layers share that branch-level state. + """ + cs = self.kv_cache.spec.chunk_size + if int(seq_len) % cs != 0: + raise AssertionError( + f"AR-Diffusion expects frame-aligned seq_len (multiple of chunk_size={cs}), got {seq_len}" + ) + + pending = self._paged_pending.get(is_negative) + if pending is not None and pending.commit_current and pending._allocated_video and not pending._committed: + raise RuntimeError("AR-Diffusion paged context replaced before its managed current chunk was committed") + + adapter = self._adapter(is_negative) + branch = "neg" if is_negative else "pos" + forward_ctx = ARDiffusionPagedForwardContext( + kv_cache=self.kv_cache, + adapter=adapter, + is_negative=is_negative, + history_block_ids=self.kv_cache.window_block_ids(adapter), + seq_len=int(seq_len), + commit_current=bool(commit_current), + max_video_tokens=int(self.kv_cache.spec.sliding_window), + ) + self._paged_pending[is_negative] = forward_ctx + _log.debug( + "AR-Diffusion GET [%s] source=paged-attn layers=%d history_blocks=%d seq_len=%d commit_current=%s", + branch, + self.num_layers, + len(forward_ctx.history_block_ids), + int(seq_len), + bool(commit_current), + ) + return [ARDiffusionPagedLayerContext(layer_idx=i, forward_ctx=forward_ctx) for i in range(self.num_layers)] + + def commit_paged_context(self, is_negative: bool) -> None: + """Commit the managed current video blocks after a successful forward.""" + ctx = self._paged_pending.get(is_negative) + if ctx is None: + return + branch = "neg" if is_negative else "pos" + if ctx.commit_current and ctx._allocated_video: + n_chunks = ctx.seq_len // self.kv_cache.spec.chunk_size + for _ in range(n_chunks): + ctx.adapter.on_chunk_committed() + self._committed[is_negative] += ctx.seq_len + _log.debug( + "AR-Diffusion COMMIT [%s] paged-attn new_tokens=%d chunks=%d resident=%d/%d", + branch, + ctx.seq_len, + n_chunks, + len(self.kv_cache.window_block_ids(ctx.adapter)), + self.kv_cache.spec.window_chunks, + ) + ctx.mark_committed() + self._paged_pending[is_negative] = None + + def get_cross_kv_caches(self, is_negative: bool) -> list[dict]: + """Return pool-backed cross-attn cache dicts. + + Under AR-Diffusion the cross-attn pool is always populated by ``_kv_populate_cross`` + before the first read, so this must never be reached unpopulated — if it + were, the model would lazily project and write cross KV itself, violating + engine ownership. Fail loud rather than fall back to the model. + """ + img_ok = not self.kv_cache._cross_k_img or self._cross_img_populated[is_negative] + if not (self._cross_text_populated[is_negative] and img_ok and self.kv_cache.cross_attn_length > 0): + raise RuntimeError( + f"AR-Diffusion cross-attn read before _kv_populate_cross (neg={is_negative}, " + f"cross_attn_length={self.kv_cache.cross_attn_length}, " + f"text={self._cross_text_populated[is_negative]} " + f"img={self._cross_img_populated[is_negative]}) — the engine must own all cross KV" + ) + return [self.kv_cache.read_cross_kv(i, is_negative) for i in range(self.num_layers)] + + def close(self) -> None: + """Final teardown: free both branches' resident pool blocks.""" + self.kv_cache.end_request(self.pos) + self.kv_cache.end_request(self.neg) + self._paged_pending = {False: None, True: None} + + def reset(self, *, keep_cross_text: bool = False) -> None: + """Drop this session's KV — mirrors the model-local ``state.reset()``. + + DreamZero resets at the attention-window boundary (``should_reset``); the + model starts a fresh sliding window, so AR-Diffusion must free the resident pool + blocks and start a fresh adapter for each branch. The ``ARDiffusionKVState`` + object (and thus ``pipeline._ar_diffusion_kv_state``) is preserved across the reset + so the runner's session mapping stays valid — only the pool-backed state + is recycled. + + Args: + keep_cross_text: On window ("inference") resets the session prompt is + unchanged, so the text cross-attn K/V in the static pool buffers are + still valid — keep them and only force the image half to repopulate. + Session resets (prompt change / explicit reset) pass ``False``. + """ + pos_id, neg_id = self.pos.request_id, self.neg.request_id + self.close() + self.pos = self.kv_cache.begin_request(pos_id) + self.neg = self.kv_cache.begin_request(neg_id) + self._committed = {False: 0, True: 0} + self._paged_pending = {False: None, True: None} + if not keep_cross_text: + self._cross_text_populated = {False: False, True: False} + self._cross_img_populated = {False: False, True: False} + _log.info( + "AR-Diffusion RESET [%s/%s] session KV cleared (window boundary; cross text %s)", + pos_id, + neg_id, + "kept" if keep_cross_text else "cleared", + ) diff --git a/vllm_omni/experimental/ar_diffusion/runner.py b/vllm_omni/experimental/ar_diffusion/runner.py new file mode 100644 index 000000000..c8b8e93e1 --- /dev/null +++ b/vllm_omni/experimental/ar_diffusion/runner.py @@ -0,0 +1,409 @@ +# SPDX-License-Identifier: Apache-2.0 +"""ARDiffusionModelRunner — the diffusion model runner for the AR-Diffusion engine. + +Subclasses ``DiffusionModelRunner`` and owns the engine-level KV cache +(:class:`ARDiffusionKVCache`). It brackets a request's rollout with the KV lifecycle +(``begin_request`` / ``end_request``) and exposes the live ``ARDiffusionKVCache`` so the +model pipeline (DreamZero) can drive the per-chunk allocate / slot-mapping / +gather / commit operations during ``pipeline.forward``. + +Selecting the AR-Diffusion engine enables KV management (no env gate); optional KV +parameters come from the deploy yaml's ``model_config["ar_diffusion_kv_config"]``. +""" + +from __future__ import annotations + +import dataclasses +import math +import time +from collections import OrderedDict + +import numpy as np +import torch +from vllm.logger import init_logger + +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import MAX_DREAMZERO_SESSIONS +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner +from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig +from vllm_omni.experimental.ar_diffusion.kv_cache.manager import ARDiffusionKVCache +from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + +logger = init_logger(__name__) + + +def resolve_ar_diffusion_kv_config(od_config: OmniDiffusionConfig) -> ARDiffusionKVConfig: + """Resolve the AR-Diffusion KV config from the deploy/command arg. + + Selecting the AR-Diffusion engine implies KV management, so the result is always + enabled. Optional overrides (``window_chunks`` / ``gpu_memory_fraction`` / + ``sink_chunks`` / ``reset_at_boundary``) may be supplied via the deploy yaml as + ``model_config["ar_diffusion_kv_config"]``. ``chunk_size`` / ``window_chunks`` are + finalized from the model geometry at load (see ``_preallocate_kv_cache``). + """ + raw = getattr(od_config, "ar_diffusion_kv_config", None) + if raw is None: + model_config = getattr(od_config, "model_config", None) + if isinstance(model_config, dict): + raw = model_config.get("ar_diffusion_kv_config") + if isinstance(raw, ARDiffusionKVConfig): + return dataclasses.replace(raw, enable=True) + if isinstance(raw, dict): + return ARDiffusionKVConfig(**{**raw, "enable": True}) + return ARDiffusionKVConfig(enable=True) + + +class ARDiffusionModelRunner(DiffusionModelRunner): + def __init__(self, vllm_config, od_config: OmniDiffusionConfig, device) -> None: + super().__init__(vllm_config, od_config, device) + self.ar_diffusion_kv_config = resolve_ar_diffusion_kv_config(od_config) + # Built after the model is loaded (dimensions known); stays None while KV + # management is disabled. + self.kv_cache: ARDiffusionKVCache | None = None + # DreamZero KV is session-scoped (persists across a session's forwards), + # so AR-Diffusion KV state is keyed by session_id and reused, not created per request. + # Bounded by the same LRU cap as DreamZeroPipeline._states: an evicted + # session's ARDiffusionKVState owns pool blocks (two CFG adapters), so without a + # bound, session-id churn in a long-running server would leak pool ownership + # until the KV pool is exhausted. Evicting frees those blocks (state.close()). + self._ar_diffusion_states: OrderedDict[str, ARDiffusionKVState] = OrderedDict() + self._max_ar_diffusion_states = MAX_DREAMZERO_SESSIONS + # Per-request server-side forward E2E times (seconds), recorded in + # execute_model. This is the worker-side compute time — the analog of the + # upstream server's per-request E2E — and excludes the engine<->worker IPC + # that the client's omni.generate wall time includes. Read (and cleared) + # by the ar_diffusion_perf_stats worker RPC for the perf-compare summary. + self._perf_e2e_times: list[float] = [] + + def build_kv_cache( + self, + *, + num_layers: int, + num_kv_heads: int, + head_size: int, + dtype, + block_size: int, + max_model_len: int, + available_bytes: int, + cross_attn_length: int = 0, + cross_attn_img_length: int = 0, + local_branches: int = 2, + num_frame_per_block: int = 1, + ) -> None: + """Construct the ARDiffusionKVCache (preallocating GPU pools on ``self.device``). + + No-op when KV management is disabled. + """ + if not self.ar_diffusion_kv_config.enable: + return + self.kv_cache = ARDiffusionKVCache( + self.ar_diffusion_kv_config, + num_layers=num_layers, + num_kv_heads=num_kv_heads, + head_size=head_size, + dtype=dtype, + block_size=block_size, + max_model_len=max_model_len, + available_bytes=available_bytes, + cross_attn_length=cross_attn_length, + cross_attn_img_length=cross_attn_img_length, + local_branches=local_branches, + num_frame_per_block=num_frame_per_block, + device=self.device, + ) + logger.info( + "AR-Diffusion KV cache enabled: %d blocks, chunk_size=%d, window_chunks=%s, " + "layers=%d kv_heads=%d head_dim=%d block_size=%d cross_attn_len=%d device=%s", + self.kv_cache.num_blocks, + self.ar_diffusion_kv_config.chunk_size, + self.ar_diffusion_kv_config.window_chunks, + num_layers, + num_kv_heads, + head_size, + block_size, + cross_attn_length, + self.device, + ) + + # -- preallocation at load ------------------------------------------------- + + def load_model(self, *args, **kwargs): + super().load_model(*args, **kwargs) + if self.ar_diffusion_kv_config.enable and self.pipeline is not None: + self._preallocate_kv_cache() + # Pre-capture the CUDA graphs for every window-fill shape at load time + # (only when compiling/cuda-graph is on) so serving is fast from chunk 0. + if not self.od_config.enforce_eager and self.ar_diffusion_kv_config.warmup_cudagraph: + self._warmup_ar_rollout() + + def _infer_frame_seqlen(self) -> int: + """frame_seqlen = (H//8)*(W//8)//4 from the configured image_resolution.""" + mc = getattr(self.od_config, "model_config", None) or {} + psc = (mc.get("policy_server_config") if isinstance(mc, dict) else None) or {} + res = psc.get("image_resolution", [180, 320]) + h, w = int(res[0]), int(res[1]) + return (h // 8) * (w // 8) // 4 + + def _preallocate_kv_cache(self) -> None: + """Build the KV pool once, from the loaded DreamZero transformer geometry.""" + # The paged self-attention path carries one rollout per request: slot + # mappings/adapters are single-sequence and K/V writes address batch + # index 0 only. Reject a multi-sequence config at init instead of + # failing (or silently dropping sequences) mid-forward. + max_num_seqs = int(getattr(self.od_config, "max_num_seqs", 1) or 1) + if max_num_seqs > 1: + raise ValueError( + "AR-Diffusion paged KV supports max_num_seqs=1 (single-sequence " + f"rollouts); got max_num_seqs={max_num_seqs}. Per-sequence " + "adapters/slot maps are not implemented." + ) + t = self.pipeline.transformer + num_layers = int(t.num_layers) + num_kv_heads = int(getattr(t.blocks[0].self_attn, "tp_num_heads", t.num_heads)) + head_size = int(t.dim // t.num_heads) + num_frame_per_block = int(t.num_frame_per_block) + local_attn_size = int(t.local_attn_size) + # The loaded transformer is the source of truth for frame_seqlen (it derives + # max_attention_size from it). The deploy-config inference is only a + # cross-check — warn on drift rather than silently sizing chunks wrong. + frame_seqlen = int(getattr(t, "frame_seqlen", 0)) or self._infer_frame_seqlen() + inferred = self._infer_frame_seqlen() + if frame_seqlen != inferred: + logger.warning( + "AR-Diffusion frame_seqlen mismatch: transformer=%d deploy-config=%d; using transformer", + frame_seqlen, + inferred, + ) + # The model's own attention window, in tokens (= the [-max_attention_size:] + # slice it applies). Read it directly rather than recomputing. + max_attention_size = int(t.blocks[0].self_attn.max_attention_size) + # Cross-attn text length — static pool for the text-encoder output. + # The cached k/v spans the full text sequence (the image tokens + # prepended in _forward_blocks are stripped inside the cross-attn + # forward but are not part of the cached k/v). + cross_attn_length = int(getattr(t, "text_len", 0)) + # I2V image-token cross-attn length: the model splits context[:, :257] as the + # image tokens (the img_emb(clip_feature) output), caching k_img/v_img. T2V: 0. + cross_attn_img_length = 257 if getattr(t, "model_type", "t2v") == "i2v" else 0 + + # Frame-granular paging: 1 block = 1 frame = frame_seqlen tokens, so the + # resident window matches max_attention_size exactly (it need not be a whole + # number of num_frame_per_block causal blocks). + chunk_size = frame_seqlen + window_chunks = self.ar_diffusion_kv_config.window_chunks or (max_attention_size // frame_seqlen) + + self.ar_diffusion_kv_config = dataclasses.replace( + self.ar_diffusion_kv_config, chunk_size=chunk_size, window_chunks=window_chunks + ) + free_bytes = torch.cuda.mem_get_info(self.device)[0] + # Under CFG-parallel each rank executes exactly ONE branch (rank0 pos, + # rank1 neg; the other branch's lazy contexts never allocate on this + # rank), so its pool only needs one branch's capacity. A single-process + # run (cfg world 1) executes both branches from one pool. + try: + from vllm_omni.diffusion.distributed.parallel_state import ( + get_classifier_free_guidance_world_size, + ) + + cfg_world = int(get_classifier_free_guidance_world_size()) + except Exception: + cfg_world = 1 + local_branches = 1 if cfg_world >= 2 else 2 + logger.info( + "AR-Diffusion preallocating (paged): frame_seqlen=%d num_frame_per_block=%d " + "local_attn_size=%d -> chunk_size=%d window_chunks=%d (window=%d tokens)", + frame_seqlen, + num_frame_per_block, + local_attn_size, + chunk_size, + window_chunks, + window_chunks * chunk_size, + ) + self.build_kv_cache( + num_layers=num_layers, + num_kv_heads=num_kv_heads, + head_size=head_size, + dtype=self.od_config.dtype, + block_size=chunk_size, # one pool block per chunk + max_model_len=1 << 20, + available_bytes=free_bytes, + cross_attn_length=cross_attn_length, + cross_attn_img_length=cross_attn_img_length, + local_branches=local_branches, + num_frame_per_block=num_frame_per_block, + ) + + def execute_model(self, req: OmniDiffusionRequest) -> DiffusionOutput: + # KV disabled -> base behavior, unchanged. + if self.kv_cache is None: + return super().execute_model(req) + + kv = self.kv_cache + # DreamZero KV is session-scoped (the model state persists across a + # session's forwards), so the AR-Diffusion KV state is keyed by session_id and + # reused — matching how pipeline.forward resolves the model-local state. + extra_args = req.sampling_params.extra_args or {} + session_id = str(extra_args.get("session_id") or "default") + state = self._ar_diffusion_states.get(session_id) + if state is None: + pos = kv.begin_request(f"bde__{session_id}") + neg = kv.begin_request(f"bde__{session_id}__neg") + state = ARDiffusionKVState(kv, pos, neg, num_layers=kv.num_layers) + self._ar_diffusion_states[session_id] = state + # Evict the least-recently-used session(s) past the cap, freeing their + # pool blocks — mirrors DreamZeroPipeline._states so the AR-Diffusion pool can't + # outlive the model-local state map it shadows. + while len(self._ar_diffusion_states) > self._max_ar_diffusion_states: + old_id, old_state = self._ar_diffusion_states.popitem(last=False) + old_state.close() + logger.debug("AR-Diffusion evicted session=%s (LRU); freed pool blocks", old_id) + # Track recency on every access (hit or miss) so the LRU order is correct. + self._ar_diffusion_states.move_to_end(session_id) + logger.debug( + "AR-Diffusion execute_model: req=%s session=%s chunk_size=%d window_chunks=%s num_blocks=%d", + req.request_id, + session_id, + kv.spec.chunk_size, + kv.config.window_chunks, + kv.num_blocks, + ) + self.pipeline._ar_diffusion_kv_state = state + # Time the worker-side forward (true per-request compute E2E). The CUDA + # sync below makes the stop reflect completed GPU work, not just dispatch. + _e2e_t0 = time.perf_counter() + try: + out = super().execute_model(req) + except Exception: + # Transactional containment: a forward that died partway may have + # written some layers' K/V into allocated-but-uncommitted blocks + # and advanced model-local state. Tear the whole session down + # (engine pool blocks freed via close(); model-local state dropped) + # so the next request with this session id starts clean instead of + # tripping the pending-commit guard or reading half-written KV. + broken = self._ar_diffusion_states.pop(session_id, None) + if broken is not None: + broken.close() + states = getattr(self.pipeline, "_states", None) + if states is not None: + states.pop(session_id, None) + logger.warning( + "AR-Diffusion execute_model failed for session=%s; session state torn down " + "(pool blocks freed) — the next request starts a fresh session", + session_id, + ) + raise + finally: + self.pipeline._ar_diffusion_kv_state = None + if self.device is not None and torch.cuda.is_available(): + torch.accelerator.synchronize(self.device) + self._perf_e2e_times.append(time.perf_counter() - _e2e_t0) + return out + + # -- cuda-graph warm-up ---------------------------------------------------- + + #: Dedicated session id for the warm-up rollout; cleaned from both state maps. + _WARMUP_SID = "__ardiffusion_warmup__" + + def _synth_robot_obs(self, h: int, w: int, n_frames: int) -> dict: + """Zero-filled roboarena observation matching the client's obs layout. + + Mirrors ``examples/.../client_schedule.make_obs_from_video`` (2 ext + wrist + cameras, 7/6/1 state vectors) so it survives ``_transform_robot_obs`` exactly + like a real request. One frame -> ``(H,W,3)``; chunk -> ``(n_frames,H,W,3)``. + """ + img = np.zeros((h, w, 3), dtype=np.uint8) if n_frames == 1 else np.zeros((n_frames, h, w, 3), dtype=np.uint8) + return { + "observation/exterior_image_0_left": img, + "observation/exterior_image_1_left": img, + "observation/wrist_image_left": img, + "observation/joint_position": np.zeros(7, dtype=np.float32), + "observation/cartesian_position": np.zeros(6, dtype=np.float32), + "observation/gripper_position": np.zeros(1, dtype=np.float32), + "prompt": "warmup", + "session_id": self._WARMUP_SID, + } + + def _warmup_ar_rollout(self) -> None: + """Drive a synthetic rollout through one window-fill so every DiT graph is + captured at load (off the serving hot path). Frees the synthetic session and + leaves the KV pool exactly as found. Never raises — falls back to lazy capture. + """ + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + kv = self.kv_cache + if kv is None: + return + sid = self._WARMUP_SID + t = self.pipeline.transformer + nfpb = int(t.num_frame_per_block) + window_chunks = int(kv.config.window_chunks or 0) + if window_chunks <= 0: + return + # Distinct DiT window shapes are walked by one fill: prefill + ceil((W-1)/nfpb) + # chunk forwards. +1 optionally to also capture the reset-cycle forward. + n_forwards = 1 + math.ceil(max(0, window_chunks - 1) / nfpb) + if self.ar_diffusion_kv_config.warmup_capture_reset: + n_forwards += 1 + res = self._image_resolution() + h, w = int(res[0]), int(res[1]) + + free_before = kv.manager.block_pool.get_num_free_blocks() + # The step cache is left active: the DiT input shape is per-chunk (the resident + # window length), and the first denoise step of every chunk always computes, so + # every window shape is captured regardless of how many later steps are skipped. + logger.info( + "AR-Diffusion cudagraph warm-up: up to %d synthetic forwards (window_chunks=%d, nfpb=%d)", + n_forwards, + window_chunks, + nfpb, + ) + try: + for i in range(n_forwards): + n_frames = 1 if i == 0 else 4 # client convention: 1-frame prefill, 4-frame chunks + obs = self._synth_robot_obs(h, w, n_frames) + sp = OmniDiffusionSamplingParams(extra_args={"reset": i == 0, "session_id": sid, "robot_obs": obs}) + req = OmniDiffusionRequest(prompts=["warmup"], sampling_params=sp, request_id=f"ardiffusion-warmup-{i}") + self.execute_model(req) + # Stop once the resident window is full — the remaining shapes are + # already captured (the window caps/resets through the same set). + ar_state = self._ar_diffusion_states.get(sid) + if ( + not self.ar_diffusion_kv_config.warmup_capture_reset + and ar_state is not None + and len(kv.window_block_ids(ar_state.pos)) >= window_chunks + ): + break + except Exception as e: # noqa: BLE001 — warm-up must never break model load + logger.warning("AR-Diffusion cudagraph warm-up failed (%s); using lazy capture.", e) + finally: + # Free the synthetic session from BOTH maps (KV pool + pipeline state). + ar_state = self._ar_diffusion_states.pop(sid, None) + if ar_state is not None: + try: + ar_state.close() # return KV blocks to the BlockPool + except Exception as e: # noqa: BLE001 + logger.warning("AR-Diffusion warm-up: state.close() failed (%s)", e) + try: + self.pipeline._states.pop(sid, None) + except Exception: # noqa: BLE001 + pass + # Drop any per-forward timings the warm-up recorded so serving stats start clean. + self._perf_e2e_times.clear() + free_after = kv.manager.block_pool.get_num_free_blocks() + if free_after != free_before: + logger.warning( + "AR-Diffusion warm-up: KV pool not restored (free %d -> %d) — possible leak", + free_before, + free_after, + ) + else: + logger.info( + "AR-Diffusion cudagraph warm-up complete; KV pool restored (%d free blocks)", + free_after, + ) + + def _image_resolution(self) -> list[int]: + mc = getattr(self.od_config, "model_config", None) or {} + psc = (mc.get("policy_server_config") if isinstance(mc, dict) else None) or {} + return psc.get("image_resolution", [180, 320]) From 02d654760fd7116554b8cb6cf3ec0c2569330928 Mon Sep 17 00:00:00 2001 From: Renzheng Wang Date: Sun, 5 Jul 2026 17:50:43 +0800 Subject: [PATCH 027/101] [BugFix] Drop removed get_cache_scale call in HunyuanImage3 diffusion loader (#4892) Signed-off-by: wangrzneu --- .../models/hunyuan_image3/hunyuan_image3_transformer.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 1212497e6..0416fdcf1 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py @@ -2235,13 +2235,8 @@ class HunyuanImage3Model(nn.Module): # processed with quantization, LoRA, fine-tuning, etc. if self.config.tie_word_embeddings and "lm_head.weight" in name: continue - if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)): - # Loading kv cache scales for compressed-tensors quantization - param = params_dict[scale_name] - weight_loader = getattr(param, "weight_loader", default_weight_loader) - loaded_weight = loaded_weight[0] - weight_loader(param, loaded_weight) - continue + # KV-cache scales are renamed via maybe_remap_kv_scale_name below; + # quant_config.get_cache_scale was removed in vLLM v0.23.0 (see #4810). is_found = False for param_name, weight_name, shard_id in stacked_params_mapping: From d7b365df64075f8784109f5b7ee9596a274075e5 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:47:06 -0800 Subject: [PATCH 028/101] [Bugfix][Qwen3-TTS] Keep seeded residual MTP sampling batched (#4889) Signed-off-by: Yueqian Lin --- .../qwen3_tts/test_code_predictor_dtype.py | 85 +++++++++++++++++++ tests/worker/test_omni_gpu_model_runner.py | 54 ++++++++++++ .../models/common/qwen3_code_predictor.py | 30 ++++++- .../models/qwen3_tts/qwen3_tts_talker.py | 10 ++- vllm_omni/worker/gpu_model_runner.py | 49 +++++++---- 5 files changed, 207 insertions(+), 21 deletions(-) diff --git a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py index 87281af82..b73c15fc7 100644 --- a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py +++ b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py @@ -339,6 +339,91 @@ class TestCodePredictorDtypeAlignment: assert not torch.equal(first[:, 1:], different[:, 1:]) +class TestCodePredictorPerRowGenerators: + """Seeded requests must stay deterministic inside a multi-row batch (#4883).""" + + def _make_predictor(self, mocker: MockerFixture, loaded_target_classes): + _, _, code_predictor_wrapper, _, _ = loaded_target_classes + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=False) + cp_config, talker_config = _make_tiny_config(loaded_target_classes) + vllm_config = _make_vllm_config(mocker, max_num_seqs=4) + predictor = code_predictor_wrapper( + vllm_config=vllm_config, + config=cp_config, + talker_config=talker_config, + ) + predictor._wrapper_config.use_cuda_graphs = False + return predictor, talker_config + + def test_multinomial_per_row_matches_single_row_draws(self, mocker: MockerFixture, loaded_target_classes) -> None: + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + multinomial = common_mod.CodePredictorWrapper._multinomial + torch.manual_seed(7) + probs = torch.softmax(torch.randn(3, 64), dim=-1) + + def seeded(seed: int) -> torch.Generator: + generator = torch.Generator(device=probs.device) + generator.manual_seed(seed) + return generator + + batched = multinomial(probs, None, [seeded(11), None, seeded(22)]) + assert batched.shape == (3, 1) + # Each seeded row must reproduce a standalone draw from the same seed, + # independent of what the other rows in the batch consume. + assert torch.equal(batched[0:1], torch.multinomial(probs[0:1], num_samples=1, generator=seeded(11))) + assert torch.equal(batched[2:3], torch.multinomial(probs[2:3], num_samples=1, generator=seeded(22))) + + def test_forward_per_row_generators_are_row_independent(self, mocker: MockerFixture, loaded_target_classes) -> None: + predictor, talker_config = self._make_predictor(mocker, loaded_target_classes) + hidden = talker_config.hidden_size + torch.manual_seed(123) + row_embed = torch.randn(1, hidden) + row_hidden = torch.randn(1, hidden) + # Three identical rows: two share a seed, one differs. + layer0_code = torch.zeros(3, dtype=torch.long) + layer0_embed = row_embed.expand(3, hidden).contiguous() + last_talker_hidden = row_hidden.expand(3, hidden).contiguous() + + def seeded(seed: int) -> torch.Generator: + generator = torch.Generator(device=layer0_code.device) + generator.manual_seed(seed) + return generator + + def run(generators): + return predictor( + layer0_code=layer0_code, + layer0_embed=layer0_embed, + last_talker_hidden=last_talker_hidden, + do_sample=True, + temperature=0.9, + top_k=50, + top_p=1.0, + generators=generators, + ) + + first = run([seeded(1234), seeded(1234), seeded(4321)]) + second = run([seeded(1234), seeded(1234), seeded(4321)]) + + # Same per-row seeds -> the whole batch reproduces across calls. + assert torch.equal(first, second) + # Identical inputs + identical seeds -> identical rows within a call. + assert torch.equal(first[0], first[1]) + # A different seed on the same inputs must diverge in the residual layers. + assert not torch.equal(first[0, 1:], first[2, 1:]) + + def test_forward_rejects_mismatched_generators_length(self, mocker: MockerFixture, loaded_target_classes) -> None: + predictor, talker_config = self._make_predictor(mocker, loaded_target_classes) + hidden = talker_config.hidden_size + with pytest.raises(ValueError, match="one entry per row"): + predictor( + layer0_code=torch.zeros(2, dtype=torch.long), + layer0_embed=torch.randn(2, hidden), + last_talker_hidden=torch.randn(2, hidden), + generators=[torch.Generator()], + ) + + class TestCodePredictorModelDtype: """Test the inner model forward with different dtypes.""" diff --git a/tests/worker/test_omni_gpu_model_runner.py b/tests/worker/test_omni_gpu_model_runner.py index e68dd6c43..48c0bc6de 100644 --- a/tests/worker/test_omni_gpu_model_runner.py +++ b/tests/worker/test_omni_gpu_model_runner.py @@ -81,6 +81,7 @@ class CaptureTalkerMTP(torch.nn.Module): top_k=None, top_p=None, generator=None, + generators=None, ): self.calls.append( { @@ -90,6 +91,7 @@ class CaptureTalkerMTP(torch.nn.Module): "top_k": top_k, "top_p": top_p, "generator": generator, + "generators": generators, } ) codes = torch.zeros((req_embeds.shape[0], 1), dtype=torch.int64) @@ -306,6 +308,7 @@ def test_talker_mtp_forward_passes_qwen3_tts_subtalker_sampling_params_to_talker "top_k": 9, "top_p": 0.55, "generator": runner.talker_mtp.calls[0]["generator"], + "generators": None, } ] assert runner.talker_mtp.calls[0]["generator"] is not None @@ -350,6 +353,57 @@ def test_talker_mtp_forward_keeps_explicit_seeded_requests_scalar(monkeypatch): assert torch.equal(runner.talker_mtp_inputs_embeds.gpu, saved_embeds) +def test_talker_mtp_forward_batches_seeded_requests_for_opted_in_models(monkeypatch): + """Models with talker_mtp_accepts_per_row_generators get one batched call (#4883).""" + import vllm_omni.worker.gpu_model_runner as mod + + monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context) + + runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4) + runner.requests["r1"].sampling_params = SimpleNamespace( + seed=11, + extra_args={"tts_local_seed": 11}, + ) + runner.requests["r2"].sampling_params = SimpleNamespace( + seed=22, + extra_args={"tts_local_seed": 22}, + ) + runner.talker_mtp = CaptureTalkerMTP() + runner.model = SimpleNamespace( + talker_mtp_output_key=("codes", "audio"), + talker_mtp_accepts_per_row_generators=True, + ) + runner.vllm_config = SimpleNamespace(model_config=SimpleNamespace(subtalker_sampling_params={})) + + def fake_determine(self, num_tokens, num_reqs, num_scheduled_tokens_np, max_num_scheduled_tokens, use_cascade_attn): + batch_desc = SimpleNamespace(num_tokens=int(num_tokens)) + return (False, batch_desc, None, None, None) + + monkeypatch.setattr(runner, "_determine_batch_execution_and_padding", fake_determine.__get__(runner, type(runner))) + + inputs_embeds = torch.zeros((6, 4), dtype=torch.float32) + OmniGPUModelRunner._talker_mtp_forward(runner, ["r1", "r2"], inputs_embeds) + + # One batched call with distinct per-row generators, not two scalar calls. + assert [call["batch_size"] for call in runner.talker_mtp.calls] == [2] + row_generators = runner.talker_mtp.calls[0]["generators"] + assert runner.talker_mtp.calls[0]["generator"] is None + assert len(row_generators) == 2 + assert all(generator is not None for generator in row_generators) + assert row_generators[0] is not row_generators[1] + + # The per-request generator stream persists across steps... + OmniGPUModelRunner._talker_mtp_forward(runner, ["r1", "r2"], inputs_embeds) + assert runner.talker_mtp.calls[1]["generators"][0] is row_generators[0] + assert runner.talker_mtp.calls[1]["generators"][1] is row_generators[1] + + # ...and is evicted once its request finishes. + del runner.requests["r2"] + OmniGPUModelRunner._talker_mtp_forward(runner, ["r1"], inputs_embeds) + assert set(runner._talker_mtp_generators) == {"r1"} + assert runner.talker_mtp.calls[2]["generator"] is row_generators[0] + + def test_update_intermediate_buffer_writes_to_buffer_and_setattr(monkeypatch): """Validate that _update_intermediate_buffer writes to model_intermediate_buffer (forward path) and mirrors to additional_information_cpu setattr (backward compat).""" diff --git a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py index eca5addba..c8591c32e 100644 --- a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py +++ b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py @@ -14,7 +14,7 @@ Shared by Qwen3-Omni and Qwen3-TTS talker models. from __future__ import annotations import dataclasses -from collections.abc import Iterable +from collections.abc import Iterable, Sequence import torch import torch.nn as nn @@ -742,6 +742,27 @@ class CodePredictorWrapper(nn.Module): # Forward -- re-prefill + inline sampling # ------------------------------------------------------------------ + @staticmethod + def _multinomial( + probs: torch.Tensor, + generator: torch.Generator | None, + generators: Sequence[torch.Generator | None] | None, + ) -> torch.Tensor: + """Sample one code per row, optionally with per-row generators. + + Per-row generators keep explicitly-seeded requests deterministic in a + multi-row batch: each row consumes draws only from its own generator, + so the transformer forward can stay batched (#4883). + """ + if generators is None: + return torch.multinomial(probs, num_samples=1, generator=generator) + return torch.cat( + [ + torch.multinomial(probs[row : row + 1], num_samples=1, generator=row_generator) + for row, row_generator in enumerate(generators) + ] + ) + @torch.inference_mode() def forward( self, @@ -753,9 +774,12 @@ class CodePredictorWrapper(nn.Module): top_k: int = 50, top_p: float = 1.0, generator: torch.Generator | None = None, + generators: Sequence[torch.Generator | None] | None = None, ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: """Predict residual codebooks 1..G-1 autoregressively via re-prefill.""" bsz = int(layer0_code.shape[0]) + if generators is not None and len(generators) != bsz: + raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}") num_groups = self._num_groups device = layer0_code.device @@ -846,7 +870,7 @@ class CodePredictorWrapper(nn.Module): sorted_logits[remove_mask] = float("-inf") logits = sorted_logits.scatter(1, sorted_idx, sorted_logits) probs = F.softmax(logits, dim=-1, dtype=torch.float32) - code = torch.multinomial(probs, num_samples=1, generator=generator) + code = self._multinomial(probs, generator, generators) else: # "per_call" mode: temperature-scaled + top-k if use_sampling: @@ -855,7 +879,7 @@ class CodePredictorWrapper(nn.Module): topk_vals, _ = scaled.topk(top_k, dim=-1) scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf")) probs = F.softmax(scaled, dim=-1, dtype=torch.float32) - code = torch.multinomial(probs, num_samples=1, generator=generator) + code = self._multinomial(probs, generator, generators) else: code = logits.argmax(dim=-1, keepdim=True) 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 c1c7f4264..347c76dfa 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py @@ -1,7 +1,7 @@ from __future__ import annotations import copy -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from typing import Any import numpy as np @@ -318,6 +318,12 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): # OmniGPUModelRunner will store talker_mtp output under this key in # per-request additional_information. self.talker_mtp_output_key = ("codes", "audio") + # talker_mtp samples with per-row generators, so explicitly-seeded + # requests stay batched instead of one scalar forward per row (#4883). + # Only valid while talker_mtp receives the unpadded active batch (this + # talker is not graph-wrapped); a padded batch would need the runner to + # pad the generators list as well. + self.talker_mtp_accepts_per_row_generators = True self.model = Qwen3Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) @@ -1056,6 +1062,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): top_k: int | None = None, top_p: float | None = None, generator: torch.Generator | None = None, + generators: Sequence[torch.Generator | None] | None = None, **kwargs: Any, ) -> tuple[torch.Tensor, torch.Tensor]: """GPU fast-path used by OmniGPUModelRunner to predict residual codebooks (1..Q-1). @@ -1095,6 +1102,7 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): top_k=top_k, top_p=top_p, generator=generator, + generators=generators, ) # [B, Q] # Map invalid layer-0 ids (e.g. EOS) to PAD=0 so SpeechTokenizer sees only real codes. diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index e40c220b8..e922146db 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -1826,7 +1826,33 @@ class OmniGPUModelRunner(GPUModelRunner): seed = extra_args.get("tts_local_seed") return int(seed) if seed is not None else None - if decode_batch_size > 1 and any(_explicit_talker_seed(req_id) is not None for req_id in decode_req_ids): + def _row_generator(req_id: str) -> torch.Generator | None: + seed = _explicit_talker_seed(req_id) + if seed is None: + return None + cache = getattr(self, "_talker_mtp_generators", None) + if cache is None: + cache = {} + self._talker_mtp_generators = cache + generator = cache.get(req_id) + if generator is None or generator.device != req_input_ids.device: + generator = torch.Generator(device=req_input_ids.device) + generator.manual_seed(seed) + cache[req_id] = generator + return generator + + row_generators = [_row_generator(req_id) for req_id in decode_req_ids] + cache = getattr(self, "_talker_mtp_generators", None) + if cache: + # Generators live as long as their request; drop finished ones. + for stale_id in [rid for rid in cache if rid not in self.requests]: + del cache[stale_id] + + if ( + decode_batch_size > 1 + and any(generator is not None for generator in row_generators) + and not getattr(self.model, "talker_mtp_accepts_per_row_generators", False) + ): # A torch.Generator is a single stream. Using one generator for a # multi-row batch would make explicitly-seeded requests depend on # other rows in the same scheduler step, so keep that path scalar. @@ -1849,28 +1875,17 @@ class OmniGPUModelRunner(GPUModelRunner): self.text_step.gpu[:decode_batch_size].copy_(saved_text) return - generator = None - if decode_req_ids: - first_req_id = decode_req_ids[0] - seed = _explicit_talker_seed(first_req_id) - if seed is not None: - generators = getattr(self, "_talker_mtp_generators", None) - if generators is None: - generators = {} - self._talker_mtp_generators = generators - generator = generators.get(first_req_id) - if generator is None or generator.device != req_input_ids.device: - generator = torch.Generator(device=req_input_ids.device) - generator.manual_seed(int(seed)) - generators[first_req_id] = generator talker_kwargs = { "do_sample": subtalker_params.get("do_sample"), "temperature": subtalker_params.get("temperature"), "top_k": subtalker_params.get("top_k"), "top_p": subtalker_params.get("top_p"), } - if generator is not None: - talker_kwargs["generator"] = generator + if decode_batch_size == 1: + if row_generators[0] is not None: + talker_kwargs["generator"] = row_generators[0] + elif any(generator is not None for generator in row_generators): + talker_kwargs["generators"] = row_generators if getattr(self.model, "talker_mtp_accepts_req_infos", False): talker_kwargs["req_ids"] = decode_req_ids talker_kwargs["req_infos"] = [ From ddba6de2ab658a0fdb6f3f72cac9d2c3e2c19aea Mon Sep 17 00:00:00 2001 From: Vensen Date: Sun, 5 Jul 2026 22:50:08 +0800 Subject: [PATCH 029/101] [Bugfix][sleep mode]: guard generation on partial wake and ensure wake idempotency (#4834) Signed-off-by: vensen --- tests/entrypoints/test_omni_sleep_mode.py | 41 +++++++++++++++++++ vllm_omni/diffusion/data.py | 8 ++++ vllm_omni/entrypoints/async_omni.py | 49 ++++++++++++++++++++--- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/tests/entrypoints/test_omni_sleep_mode.py b/tests/entrypoints/test_omni_sleep_mode.py index c2a5ff0a1..4be847cb9 100644 --- a/tests/entrypoints/test_omni_sleep_mode.py +++ b/tests/entrypoints/test_omni_sleep_mode.py @@ -407,6 +407,47 @@ class TestOmniSleepMode: diffusion_engine.shutdown() await asyncio.sleep(1) + @pytest.mark.asyncio + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + async def test_level2_sleep_wake_raises(self, llm_engine: AsyncOmni): + """Regression for #4473 Repro A: wake_up() after sleep(level=2) must raise + NotImplementedError instead of silently producing corrupted output.""" + try: + await llm_engine.sleep(stage_ids=[0], level=2) + with pytest.raises(NotImplementedError, match="sleep\\(level=2\\)"): + await llm_engine.wake_up(stage_ids=[0]) + finally: + llm_engine.shutdown() + + @pytest.mark.asyncio + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni): + """Regression for #4473 Repro B: generate() must be rejected if kv_cache + is still asleep after wake_up(tags=["weights"]), instead of crashing with + CUDA illegal memory access.""" + try: + await llm_engine.sleep(stage_ids=[0], level=1) + await llm_engine.wake_up(stage_ids=[0], tags=["weights"]) + with pytest.raises(RuntimeError, match="partially or fully asleep"): + async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)): + pass + finally: + llm_engine.shutdown() + + @pytest.mark.asyncio + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni): + """Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a + safe no-op instead of raising a cumem CUDA invalid argument error.""" + try: + await llm_engine.sleep(stage_ids=[0], level=1) + first_acks = await llm_engine.wake_up(stage_ids=[0]) + assert len(first_acks) > 0, "First wake_up() should return ACKs" + second_acks = await llm_engine.wake_up(stage_ids=[0]) + assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}" + finally: + llm_engine.shutdown() + # --------------------------------------------------------------------------- # H100 (or MI325) e2e: BAGEL / pure diffusion, multi-TP diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 7362e5cf7..3d7e1bcfc 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -6,6 +6,7 @@ import os import random from collections.abc import Callable, Mapping from dataclasses import dataclass, field, fields +from enum import Enum from typing import TYPE_CHECKING, Any import diffusers @@ -1488,5 +1489,12 @@ class OmniWakeTask: tags: list[str] | None = None +class CuMemTag(str, Enum): + """Tags representing specific CuMem allocations for sleep/wake state tracking.""" + + WEIGHTS = "weights" + KV_CACHE = "kv_cache" + + # Special message broadcast via scheduler queues to signal worker shutdown. SHUTDOWN_MESSAGE = {"type": "shutdown"} diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index ab2d8836b..ce7ea8817 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -26,7 +26,7 @@ from vllm.tasks import SupportedTask from vllm.utils import random_uuid from vllm.v1.engine.exceptions import EngineDeadError -from vllm_omni.diffusion.data import OmniACK, OmniSleepTask, OmniWakeTask +from vllm_omni.diffusion.data import CuMemTag, OmniACK, OmniSleepTask, OmniWakeTask from vllm_omni.engine.messages import ErrorMessage, OutputMessage from vllm_omni.entrypoints.client_request_state import ClientRequestState from vllm_omni.entrypoints.omni_base import ( @@ -141,7 +141,8 @@ class AsyncOmni(EngineClient, OmniBase): OmniBase.__init__(self, model=model, **kwargs) self._pause_cond: asyncio.Condition = asyncio.Condition() self._paused: bool = False - self._is_sleeping: bool = False + self._sleeping_tags: set[str] = set() + self._level2_sleeping: bool = False self.final_output_task: asyncio.Task | None = None self.event_resolver = AsyncEventResolver(orchestrator=self) self.config_path = self.engine.config_path @@ -306,6 +307,14 @@ class AsyncOmni(EngineClient, OmniBase): logger.debug(f"[AsyncOmni] generate() called for request {external_request_id}") + _sleeping_tags = getattr(self, "_sleeping_tags", None) + if _sleeping_tags: + raise RuntimeError( + f"Generation rejected: Engine is partially or fully asleep. " + f"Currently sleeping tags: {list(_sleeping_tags)}. " + f"Please perform a full wake_up before generating." + ) + # Reject diffusion list-prompt early with a clear API error. if isinstance(prompt, list) and any( getattr(client, "stage_type", "") == "diffusion" for client in getattr(self.engine, "stage_clients", []) @@ -922,11 +931,32 @@ class AsyncOmni(EngineClient, OmniBase): if ack is not None: await self.event_resolver.resolve(ack) final_acks.append(ack) - self._is_sleeping = True + if not hasattr(self, "_sleeping_tags"): + self._sleeping_tags = set() + self._sleeping_tags.update([CuMemTag.WEIGHTS.value, CuMemTag.KV_CACHE.value]) + if level == 2: + self._level2_sleeping = True return final_acks async def wake_up(self, stage_ids: list[int] | None = None, tags: list[str] | None = None) -> list[OmniACK]: self._final_output_handler() + + if getattr(self, "_level2_sleeping", False): + raise NotImplementedError( + "wake_up() after sleep(level=2) is not yet implemented: weights were " + "discarded from GPU and reloading from disk is not yet supported. " + "Use sleep(level=1) instead, which offloads weights to CPU RAM " + "and supports fast DMA restore." + ) + _current_tags = getattr(self, "_sleeping_tags", set()) + if tags is None: + requested_tags = list(_current_tags) + else: + requested_tags = [t for t in tags if t in _current_tags] + if not requested_tags: + logger.info(f"[{self._name}] Requested tags {tags} are already warm. Skipping wake_up.") + return [] + if stage_ids is None: stage_ids = list(range(len(self.engine.stage_clients))) total_workers = 0 @@ -940,7 +970,7 @@ class AsyncOmni(EngineClient, OmniBase): task_id = str(uuid.uuid4()) self.event_resolver.watch_task(task_id, expected_count=total_workers) logger.info(f"[{self._name}] Wake-up initiated (Task: {task_id}). Awaiting {total_workers} ACKs...") - task = OmniWakeTask(tags=tags, task_id=task_id) + task = OmniWakeTask(tags=requested_tags, task_id=task_id) rpc_results = await self.collective_rpc(method="handle_wake_task", args=(task,), stage_ids=stage_ids) final_acks = [] for stage_res in rpc_results: @@ -951,7 +981,14 @@ class AsyncOmni(EngineClient, OmniBase): final_acks.append(ack) current_omni_platform.synchronize() await asyncio.sleep(0.1) - self._is_sleeping = False + + for t in requested_tags: + if hasattr(self, "_sleeping_tags"): + self._sleeping_tags.discard(t) + # Only clear the level-2 flag once all tags are warm, in case partial + # wake support (e.g. tags=["kv_cache"] only) is added in the future. + if not getattr(self, "_sleeping_tags", None): + self._level2_sleeping = False logger.info(f"[{self._name}] All {len(final_acks)}/{total_workers} workers reported WARM for task {task_id}.") return final_acks @@ -961,7 +998,7 @@ class AsyncOmni(EngineClient, OmniBase): TODO(AsyncOmni): query the orchestrator once all stage backends expose a real sleeping-state RPC. For now we track the requested state locally. """ - return self._is_sleeping + return bool(getattr(self, "_sleeping_tags", None)) async def add_lora(self, lora_request: LoRARequest) -> bool: """Load a new LoRA adapter into all stages. From 66ec3a2aff983d8017e9069fbc78adc6f70a08a2 Mon Sep 17 00:00:00 2001 From: TaffyOfficial <2587297563@qq.com> Date: Sun, 5 Jul 2026 23:37:42 +0800 Subject: [PATCH 030/101] [Bugfix] Fix HunyuanImage3 MoE groups for vLLM 0.24 (#4893) Signed-off-by: zuiho <2324465096@qq.com> Co-authored-by: zuiho <2324465096@qq.com> --- .../test_expert_parallel_layout.py | 35 +++++++++++- .../hunyuan_image3/test_hunyuan_fused_moe.py | 42 ++++++++++++++ .../diffusion/distributed/parallel_state.py | 56 +++++++++++++++---- .../hunyuan_image3/hunyuan_fused_moe.py | 27 +++++++++ 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/tests/diffusion/distributed/test_expert_parallel_layout.py b/tests/diffusion/distributed/test_expert_parallel_layout.py index 8fc1db605..c4d3c6ae7 100644 --- a/tests/diffusion/distributed/test_expert_parallel_layout.py +++ b/tests/diffusion/distributed/test_expert_parallel_layout.py @@ -19,6 +19,10 @@ class _FakeGroup: self.group_ranks = group_ranks self.parallel_mode = parallel_mode self.device_group = object() + self.device_communicator = kwargs.get("device_communicator") + reduce_scatter = kwargs.get("reduce_scatter") + if reduce_scatter is not None: + self.reduce_scatter = reduce_scatter self.ulysses_group = kwargs.get("ulysses_group") self.ring_group = kwargs.get("ring_group") self.local_group = next(group for group in group_ranks if local_rank in group) @@ -55,6 +59,23 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): created_groups.append(group) return group + def fake_init_vllm_model_parallel_group( + group_ranks, + local_rank, + backend, + group_name, + ): + del backend + group = _FakeGroup( + [list(ranks) for ranks in group_ranks], + local_rank, + f"vllm_{group_name}", + device_communicator=object(), + reduce_scatter=lambda tensor, **kwargs: tensor, + ) + created_groups.append(group) + return group + fake_world_group = SimpleNamespace( rank_in_group=local_rank, local_rank=local_rank, @@ -68,6 +89,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): monkeypatch.setattr(parallel_state, "get_world_group", lambda: fake_world_group) monkeypatch.setattr(parallel_state, "get_forward_context", lambda: fake_forward_context) monkeypatch.setattr(parallel_state, "init_model_parallel_group", fake_init_model_parallel_group) + monkeypatch.setattr(parallel_state, "init_vllm_model_parallel_group", fake_init_vllm_model_parallel_group) monkeypatch.setattr(parallel_state, "init_dit_group", lambda *_args, **_kwargs: None) for name in ("_DP", "_CFG", "_SP", "_PP", "_FS", "_EXPERT_PARALLEL_GROUP_RANKS"): @@ -87,7 +109,7 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): backend="gloo", ) - assert parallel_state.vllm_parallel_state._PCP is parallel_state._SP + assert parallel_state.vllm_parallel_state._PCP is not parallel_state._SP assert parallel_state.vllm_parallel_state._PCP.world_size == 2 assert parallel_state._DP.world_size == 2 assert parallel_state.vllm_parallel_state._DP is not parallel_state._DP @@ -96,6 +118,13 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): assert parallel_state.vllm_parallel_state._TP.world_size == 2 assert parallel_state._PP.world_size == 2 + assert parallel_state.vllm_parallel_state._PCP.device_communicator is not None + assert parallel_state.vllm_parallel_state._DP.device_communicator is not None + assert parallel_state.vllm_parallel_state._EP.device_communicator is not None + assert hasattr(parallel_state.vllm_parallel_state._PCP, "reduce_scatter") + assert hasattr(parallel_state.vllm_parallel_state._DP, "reduce_scatter") + assert hasattr(parallel_state.vllm_parallel_state._EP, "reduce_scatter") + assert parallel_state.vllm_parallel_state._PCP.local_group == [0, 2] assert parallel_state.vllm_parallel_state._DP.local_group == [0, 8, 16, 24] assert parallel_state.vllm_parallel_state._EP.local_group == [ @@ -155,7 +184,9 @@ def test_moe_ep_maps_diffusion_sp_cfg_dp_to_vllm_groups(monkeypatch): ], ] - ep_groups = [group.local_group for group in created_groups if group.parallel_mode == "expert"] + vllm_group_names = [group.parallel_mode for group in created_groups if group.parallel_mode.startswith("vllm_")] + assert vllm_group_names == ["vllm_pcp", "vllm_tp", "vllm_dp", "vllm_ep"] + ep_groups = [group.local_group for group in created_groups if group.parallel_mode == "vllm_ep"] assert ep_groups == [parallel_state.vllm_parallel_state._EP.local_group] diff --git a/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py b/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py index 626f78eed..e8ade2e51 100644 --- a/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py +++ b/tests/diffusion/models/hunyuan_image3/test_hunyuan_fused_moe.py @@ -50,6 +50,48 @@ class TestSetForwardContextNumTokens: mock_get.assert_not_called() +class TestSetForwardContextDPMetadata: + """Test diffusion MoE DP metadata setup for vLLM 0.24 all2all dispatch.""" + + def test_sets_dp_metadata_from_vllm_dp_group(self, mocker): + import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe + + mock_ctx = mocker.MagicMock() + mock_ctx.dp_metadata = None + fake_group = mocker.MagicMock() + fake_group.world_size = 2 + fake_group.cpu_group = object() + + def _all_gather_object(output, obj, group): + assert group is fake_group.cpu_group + output[:] = [obj, obj + 1] + + mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + mocker.patch.object(hunyuan_moe._vllm_ps, "_DP", fake_group, create=True) + mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object", side_effect=_all_gather_object) + + hunyuan_moe._set_forward_context_dp_metadata(1024) + + assert mock_ctx.dp_metadata is not None + assert mock_ctx.dp_metadata.num_tokens_across_dp_cpu.tolist() == [1024, 1025] + + def test_keeps_existing_dp_metadata(self, mocker): + import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe + + existing_metadata = object() + mock_ctx = mocker.MagicMock() + mock_ctx.dp_metadata = existing_metadata + mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=True) + mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context", return_value=mock_ctx) + mock_all_gather = mocker.patch.object(hunyuan_moe.torch.distributed, "all_gather_object") + + hunyuan_moe._set_forward_context_dp_metadata(1024) + + assert mock_ctx.dp_metadata is existing_metadata + mock_all_gather.assert_not_called() + + class TestHunyuanFusedMoEPlatformDispatch: """Test platform dispatch via platform qualname hooks.""" diff --git a/vllm_omni/diffusion/distributed/parallel_state.py b/vllm_omni/diffusion/distributed/parallel_state.py index 5904ae94f..7b38d3775 100644 --- a/vllm_omni/diffusion/distributed/parallel_state.py +++ b/vllm_omni/diffusion/distributed/parallel_state.py @@ -512,6 +512,21 @@ def init_model_parallel_group( ) +def init_vllm_model_parallel_group( + group_ranks: list[list[int]], + local_rank: int, + backend: str, + group_name: str, +) -> vllm_parallel_state.GroupCoordinator: + return vllm_parallel_state.init_model_parallel_group( + group_ranks=group_ranks, + local_rank=local_rank, + backend=backend, + group_name=group_name, + use_device_communicator=True, + ) + + def init_dit_group( dit_parallel_size: int, backend: str, @@ -838,23 +853,40 @@ def initialize_model_parallel( if use_moe_parallel_mapping: # Diffusion normally uses its own SP group. Map it to vLLM PCP only for # expert-parallel runtimes that rely on vLLM FusedMoE group semantics. - vllm_parallel_state._PCP = _SP + # vLLM 0.24 MoE kernels require GroupCoordinator.device_communicator + # and reduce_scatter(), which the diffusion SP coordinator intentionally + # does not own. Keep the rank layout but build a vLLM coordinator. + vllm_parallel_state._PCP = init_vllm_model_parallel_group( + group_ranks=sp_group_ranks, + local_rank=get_world_group().local_rank, + backend=backend, + group_name="pcp", + ) assert vllm_parallel_state._TP is None, "Tensor parallel group is already initialized" - vllm_parallel_state._TP = init_model_parallel_group( - group_ranks=rank_generator.get_ranks("tp"), - local_rank=get_world_group().local_rank, - backend=backend, - parallel_mode="tensor", - ) - if use_moe_parallel_mapping and cfg_parallel_size > 1: + tp_group_ranks = rank_generator.get_ranks("tp") + if use_moe_parallel_mapping: + vllm_parallel_state._TP = init_vllm_model_parallel_group( + group_ranks=tp_group_ranks, + local_rank=get_world_group().local_rank, + backend=backend, + group_name="tp", + ) + else: + vllm_parallel_state._TP = init_model_parallel_group( + group_ranks=tp_group_ranks, + local_rank=get_world_group().local_rank, + backend=backend, + parallel_mode="tensor", + ) + if use_moe_parallel_mapping: # CFG is a diffusion-specific replica dimension. Fold it into vLLM DP # only when constructing the vLLM EP layout for expert-parallel paths. - vllm_parallel_state._DP = init_model_parallel_group( + vllm_parallel_state._DP = init_vllm_model_parallel_group( group_ranks=rank_generator.get_ranks("cfg-dp"), local_rank=get_world_group().local_rank, backend=backend, - parallel_mode="data", + group_name="dp", ) global _FS, _EXPERT_PARALLEL_GROUP_RANKS @@ -869,11 +901,11 @@ def initialize_model_parallel( _EXPERT_PARALLEL_GROUP_RANKS = None if use_moe_parallel_mapping: ep_group_ranks = rank_generator.get_ranks("tp-sp-cfg-dp") - vllm_parallel_state._EP = init_model_parallel_group( + vllm_parallel_state._EP = init_vllm_model_parallel_group( group_ranks=ep_group_ranks, local_rank=get_world_group().local_rank, backend=backend, - parallel_mode="expert", + group_name="ep", ) _EXPERT_PARALLEL_GROUP_RANKS = ep_group_ranks diff --git a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py index f9f114513..fc1317a48 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_fused_moe.py @@ -3,6 +3,8 @@ from typing import Any +import torch +import vllm.distributed.parallel_state as _vllm_ps import vllm.forward_context as _vllm_fc from vllm.utils.import_utils import resolve_obj_by_qualname @@ -24,6 +26,30 @@ def _set_forward_context_num_tokens(num_tokens: int) -> None: forward_context.in_profile_run = False +def _set_forward_context_dp_metadata(num_tokens: int) -> None: + """Populate vLLM MoE DP token metadata for diffusion-only DP mappings.""" + if not _vllm_fc.is_forward_context_available(): + return + forward_context = _vllm_fc.get_forward_context() + if getattr(forward_context, "dp_metadata", None) is not None: + return + + dp_group = getattr(_vllm_ps, "_DP", None) + if dp_group is None or getattr(dp_group, "world_size", 1) <= 1: + return + + gathered_num_tokens: list[int | None] = [None] * dp_group.world_size + torch.distributed.all_gather_object( + gathered_num_tokens, + int(num_tokens), + group=dp_group.cpu_group, + ) + if any(count is None for count in gathered_num_tokens): + raise RuntimeError(f"Failed to gather MoE DP token counts: {gathered_num_tokens}") + + forward_context.dp_metadata = _vllm_fc.DPMetadata(torch.tensor(gathered_num_tokens, dtype=torch.int64)) + + class HunyuanFusedMoEDefault: """Adapter that configures the upstream FusedMoE ``MoERunner`` for HunyuanImage3. @@ -62,6 +88,7 @@ class HunyuanFusedMoEDefault: hidden_states = args[0] if hidden_states is not None: _set_forward_context_num_tokens(hidden_states.shape[0]) + _set_forward_context_dp_metadata(hidden_states.shape[0]) moe_runner.register_forward_pre_hook(_num_tokens_pre_hook, with_kwargs=True) From 862e0a2b27a92638fd2e31b30f4f9293a73f7c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Mon, 6 Jul 2026 01:06:30 +0800 Subject: [PATCH 031/101] [BugFix] Preserve diffusion extra_body params for out-of-process CFG Parallel (#4900) Signed-off-by: princepride --- vllm_omni/entrypoints/async_omni.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index ce7ea8817..3fe0f8165 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -209,10 +209,13 @@ class AsyncOmni(EngineClient, OmniBase): def get_diffusion_od_config(self) -> Any | None: """Return the diffusion-stage config when the pipeline has one.""" + saw_diffusion_stage = False for stage_client in self.engine.stage_clients: if getattr(stage_client, "stage_type", None) != "diffusion": continue + saw_diffusion_stage = True + od_config = getattr(stage_client, "od_config", None) if od_config is not None: return od_config @@ -222,6 +225,11 @@ class AsyncOmni(EngineClient, OmniBase): if od_config is not None: return od_config + # Out-of-process diffusion clients don't carry od_config (it lives in the + # worker); fall back to the engine's model_class_name resolution. + if saw_diffusion_stage: + return self.engine.get_diffusion_od_config() + return None @property From 14fad9835d6a5a8f1c01bc620ec5917bfef8bd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Mon, 6 Jul 2026 01:26:09 +0800 Subject: [PATCH 032/101] [BugFix]: fix #3236 bug (#4877) Signed-off-by: princepride --- tests/e2e/offline_inference/test_magi_human_expansion.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/e2e/offline_inference/test_magi_human_expansion.py b/tests/e2e/offline_inference/test_magi_human_expansion.py index 5ce016988..e6b3c6a42 100644 --- a/tests/e2e/offline_inference/test_magi_human_expansion.py +++ b/tests/e2e/offline_inference/test_magi_human_expansion.py @@ -22,6 +22,7 @@ _OMNI_RUNNER_PARAM = ( { "init_timeout": 1200, "tensor_parallel_size": 2, + "vae_use_tiling": True, }, ) @@ -32,6 +33,7 @@ _OMNI_RUNNER_PARAM_OFFLOAD = ( "init_timeout": 1200, "tensor_parallel_size": 2, "enable_cpu_offload": True, + "vae_use_tiling": True, }, ) @@ -45,10 +47,6 @@ pytestmark = [ ), ] -_SKIP_ISSUE_3236 = pytest.mark.skip( - reason="https://github.com/vllm-project/vllm-omni/issues/3236", -) - def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None: """Validate that the MP4 contains meaningful video and audio tracks.""" @@ -74,7 +72,6 @@ def _validate_mp4(video_bytes: bytes, min_frames: int = 10) -> None: container.close() -@_SKIP_ISSUE_3236 @hardware_test(res={"cuda": "H100"}, num_cards=2) def test_magi_human_e2e(omni_runner: OmniRunner): """End-to-end test for MagiHuman generating video and audio.""" From ba7e9e64960a86d2d40b0dd014fe101414c96d01 Mon Sep 17 00:00:00 2001 From: Vensen Date: Mon, 6 Jul 2026 15:07:01 +0800 Subject: [PATCH 033/101] [CI][sleep mode] fix sleep pytest CI error (#4912) Signed-off-by: vensen --- tests/entrypoints/test_omni_sleep_mode.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/entrypoints/test_omni_sleep_mode.py b/tests/entrypoints/test_omni_sleep_mode.py index 4be847cb9..efbb19fff 100644 --- a/tests/entrypoints/test_omni_sleep_mode.py +++ b/tests/entrypoints/test_omni_sleep_mode.py @@ -179,7 +179,7 @@ class TestOmniSleepMode: try: device_id = 0 used_before = get_device_global_memory_used_gib(device_id) - acks = await llm_engine.sleep(stage_ids=[0], level=2) + acks = await llm_engine.sleep(stage_ids=[0], level=1) await asyncio.sleep(1.5) used_after = get_device_global_memory_used_gib(device_id) drop_gib = used_before - used_after @@ -209,7 +209,7 @@ class TestOmniSleepMode: """Diffusion Worker stage signal loop""" try: logger.info("Starting Diffusion Worker Handshake Test") - acks = await diffusion_engine.sleep(stage_ids=[0], level=2) + acks = await diffusion_engine.sleep(stage_ids=[0], level=1) def _get_status(ack): return ack.status if hasattr(ack, "status") else ack.get("status") @@ -264,9 +264,9 @@ class TestOmniSleepMode: base_output = output assert base_output is not None and len(base_output.images) > 0 logger.info("Baseline Generation successful.") - # Sleep Level 2 + # Sleep Level 1 logger.info("Entering Deep Sleep (VRAM Scavenging)...") - await diffusion_engine.sleep(stage_ids=[0], level=2) + await diffusion_engine.sleep(stage_ids=[0], level=1) # Wake-up logger.info("Waking up (Reloading Weights)...") await diffusion_engine.wake_up(stage_ids=[0]) @@ -353,8 +353,8 @@ class TestOmniSleepMode: logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB") # Sleep - logger.info("Triggering Level 2 Deep Sleep (Full Weight Offloading)...") - acks = await diffusion_engine.sleep(stage_ids=[0], level=2) + logger.info("Triggering Level 1 Deep Sleep (Partial Weight Offloading)...") + acks = await diffusion_engine.sleep(stage_ids=[0], level=1) reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks) reported_freed_gib = reported_freed_bytes / 1024**3 @@ -485,7 +485,7 @@ async def test_diffusion_model_sleep_tp(tp_size: int): async for _ in engine.generate("test", sampling_params_list=[llm_sp, diff_sp]): pass - acks = await engine.sleep(level=2) + acks = await engine.sleep(level=1) statuses = [get_ack_info(ack, "status") for ack in acks] assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}" @@ -541,7 +541,7 @@ async def test_multistage_sleep_h100(tp_size: int): async for _ in engine.generate("warmup", sampling_params_list=[SamplingParams(), sp]): pass - acks = await engine.sleep(stage_ids=[0, 1], level=2) + acks = await engine.sleep(stage_ids=[0, 1], level=1) assert len(acks) == 2 await engine.wake_up(stage_ids=[0, 1]) From 9ceca4ec85c21d1806ea69d74002e0f9e96bbca7 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Mon, 6 Jul 2026 15:08:05 +0800 Subject: [PATCH 034/101] [Bugfix] Fix full-payload mm splitting for dual hidden/scheduled batch axes (#4870 follow-up) (#4910) Signed-off-by: amy-why-3459 --- tests/worker/test_gpu_ar_model_runner.py | 46 +++++++++++++++++++ .../npu/worker/npu_ar_model_runner.py | 6 ++- vllm_omni/utils/mm_outputs.py | 44 ++++++++++++++---- vllm_omni/worker/gpu_ar_model_runner.py | 18 +++++--- 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/tests/worker/test_gpu_ar_model_runner.py b/tests/worker/test_gpu_ar_model_runner.py index b56917fdb..90cf3afe1 100644 --- a/tests/worker/test_gpu_ar_model_runner.py +++ b/tests/worker/test_gpu_ar_model_runner.py @@ -480,6 +480,52 @@ def test_build_omni_output_splits_mm_by_scheduled_tokens_when_hidden_is_tail_onl assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], codes[2:3]) +def test_build_omni_output_splits_mm_by_hidden_len_when_scheduled_is_padded(monkeypatch): + """Thinker mm rows align to hidden_states.shape[0], not padded scheduled count.""" + runner = _make_async_output_runner(engine_output_type="latent") + runner.model.omni_pooler_payload_include_hidden = False + runner._async_chunk = False + runner.requests = {"r1": object(), "r2": object(), "r3": object()} + + monkeypatch.setattr( + GPUARModelRunner, + "_resolve_pooler_payload_req_ids", + lambda self, req_ids: ("latent", req_ids), + ) + monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False) + monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None) + monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None) + + layers = torch.arange(96, dtype=torch.long).reshape(3, 32) + output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot( + runner, + scheduler_output=SimpleNamespace( + total_num_scheduled_tokens=5, + num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1}, + ), + hidden_states=torch.randn(3, 4), + staged_hidden_states_cpu=None, + multimodal_outputs={"hidden_states": {"layers": {0: layers}}}, + req_ids_output_copy=["r1", "r2", "r3"], + req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2}, + valid_sampled_token_ids=[[101], [102], [103]], + logprobs_lists=None, + prompt_logprobs_dict={}, + num_nans_in_logits=None, + kv_connector_output=None, + ec_connector_output=None, + cudagraph_stats=None, + kv_extracted_req_ids=None, + num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32), + query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long), + ) + + assert output.inter_stage_outputs is not None + assert torch.equal(output.inter_stage_outputs[0]["hidden_states.layer_0"], layers[0:1]) + assert torch.equal(output.inter_stage_outputs[1]["hidden_states.layer_0"], layers[1:2]) + assert torch.equal(output.inter_stage_outputs[2]["hidden_states.layer_0"], layers[2:3]) + + def test_async_snapshot_payload_omits_hidden_when_model_opts_out(): runner = _make_async_output_runner() runner.model.omni_pooler_payload_include_hidden = False diff --git a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py index 43d2c9d19..a0abc7fa2 100644 --- a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py @@ -902,7 +902,8 @@ class NPUARModelRunner(OmniNPUModelRunner): ) = self.execute_model_state # Clear ephemeral state. self.execute_model_state = None - seq_len = hidden_states.shape[0] + hidden_seq_len = int(hidden_states.shape[0]) + scheduled_seq_len = int(scheduler_output.total_num_scheduled_tokens) # Apply structured output bitmasks if present. if grammar_output is not None: @@ -1141,7 +1142,8 @@ class NPUARModelRunner(OmniNPUModelRunner): start=start, end=end, pass_lists_through=False, - seq_len=seq_len, + seq_len=hidden_seq_len, + scheduled_seq_len=scheduled_seq_len, ) payload.update(mm_payload) pooler_output.append(flatten_payload(payload)) diff --git a/vllm_omni/utils/mm_outputs.py b/vllm_omni/utils/mm_outputs.py index 0ca0c2c3a..189cc00aa 100644 --- a/vllm_omni/utils/mm_outputs.py +++ b/vllm_omni/utils/mm_outputs.py @@ -106,7 +106,13 @@ def _to_cpu(value): def to_payload_element( - element: object, idx: int, start: int, end: int, pass_lists_through: bool = False, seq_len: int | None = None + element: object, + idx: int, + start: int, + end: int, + pass_lists_through: bool = False, + seq_len: int | None = None, + scheduled_seq_len: int | None = None, ): """Build an mm payload element corresponding to one request index from an element containing 0 or more CPU tensors. @@ -120,21 +126,39 @@ def to_payload_element( passthrough data; this should be False in normal cases, but True if we need to avoid splitting nonempty lists prior to calling postprocess, which is the case for prefix cache. - seq_len: Optional sequence length (i.e., dim 0 of hidden states). - When set, a tensor whose first dimension equals seq_len is - sliced per request. The prefix cache passthrough also passes - the total scheduled token count here so 1D (seq_len,) metadata - that is intentionally not cached is still split per request. + seq_len: Optional hidden-aligned batch length (``hidden_states.shape[0]``). + When a tensor's first dimension equals this value, it is sliced + per request using ``start:end``. + scheduled_seq_len: Optional scheduler-aligned batch length + (``scheduler_output.total_num_scheduled_tokens``). Some full-payload + mm tensors (e.g. batched ``codes.audio`` with tail-only hidden states) + are laid out by scheduled tokens instead of the hidden tail shape. + When omitted, ``seq_len`` is reused for backward compatibility. """ - # Cached per-token tensors are merged elsewhere; here a first dim - # equal to seq_len means a per-request slice is required. - if seq_len is not None and isinstance(element, torch.Tensor) and element.shape[0] == seq_len: + if scheduled_seq_len is None: + scheduled_seq_len = seq_len + + # Cached per-token tensors are merged elsewhere; here a first dim equal to + # either the hidden-aligned or scheduler-aligned batch length means a + # per-request slice is required. + if isinstance(element, torch.Tensor) and ( + (seq_len is not None and element.shape[0] == seq_len) + or (scheduled_seq_len is not None and element.shape[0] == scheduled_seq_len) + ): return element[start:end].contiguous() # Every other case is shared between prefix cache (passthrough data) # and running a model without prefix caching. elif isinstance(element, dict): return { - sk: to_payload_element(sv, idx, start, end, pass_lists_through=pass_lists_through, seq_len=seq_len) + sk: to_payload_element( + sv, + idx, + start, + end, + pass_lists_through=pass_lists_through, + seq_len=seq_len, + scheduled_seq_len=scheduled_seq_len, + ) for sk, sv in element.items() } elif isinstance(element, list): diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 2b44e7b45..2bf7a8af2 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -839,7 +839,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): end: int, audio_sparse_output: bool, sparse_mm_index: dict[str, int], - seq_len: int, + hidden_seq_len: int, + scheduled_seq_len: int, ) -> dict[str, object]: if combined_multimodal_outputs: return self._build_combined_prefix_cache_mm_payload( @@ -876,7 +877,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): start=start, end=end, pass_lists_through=False, - seq_len=seq_len, + seq_len=hidden_seq_len, + scheduled_seq_len=scheduled_seq_len, ) return mm_payload @@ -894,7 +896,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): mm_cpu: dict[str, object] | None, audio_sparse_output: bool, sparse_mm_index: dict[str, int], - seq_len: int, + hidden_seq_len: int, + scheduled_seq_len: int, ) -> dict[str, object]: payload: dict[str, object] = {} if not audio_sparse_output: @@ -920,7 +923,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): end=end, audio_sparse_output=audio_sparse_output, sparse_mm_index=sparse_mm_index, - seq_len=seq_len, + hidden_seq_len=hidden_seq_len, + scheduled_seq_len=scheduled_seq_len, ) payload.update(mm_payload) return payload @@ -1731,7 +1735,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): # The actual multimodal wire transport uses multimodal_outputs instead. pooler_output: list[dict[str, object]] | None = None if needs_pooler_payload: - mm_seq_len = int(scheduler_output.total_num_scheduled_tokens) + hidden_seq_len = int(hidden_states.shape[0]) + scheduled_seq_len = int(scheduler_output.total_num_scheduled_tokens) mm_cpu = None if self.omni_prefix_cache is not None: ( @@ -1787,7 +1792,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): mm_cpu=mm_cpu, audio_sparse_output=audio_sparse_output, sparse_mm_index=sparse_mm_index, - seq_len=mm_seq_len, + hidden_seq_len=hidden_seq_len, + scheduled_seq_len=scheduled_seq_len, ) pooler_output.append(flatten_payload(payload)) From d4a869fe5e2edd49af48026051948c8d1018d727 Mon Sep 17 00:00:00 2001 From: herotai214 <68222888+herotai214@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:53:24 +0800 Subject: [PATCH 035/101] [Bugfix] Fix tokenizer config for higgs-audio-v3 test error (#4918) Signed-off-by: herotai214 --- .../e2e/online_serving/test_higgs_audio_v3.py | 2 +- .../higgs_audio_v3/test_higgs_audio_v3.py | 32 ++++++++++++++++++- .../higgs_audio_v2_tokenizer.py | 24 +++++++++++--- 3 files changed, 52 insertions(+), 6 deletions(-) mode change 100644 => 100755 tests/unit/higgs_audio_v3/test_higgs_audio_v3.py mode change 100644 => 100755 vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py diff --git a/tests/e2e/online_serving/test_higgs_audio_v3.py b/tests/e2e/online_serving/test_higgs_audio_v3.py index 2ead1b5da..d26e6bff4 100644 --- a/tests/e2e/online_serving/test_higgs_audio_v3.py +++ b/tests/e2e/online_serving/test_higgs_audio_v3.py @@ -127,7 +127,7 @@ class TestHiggsAudioV3OnlineHappyPath: 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 + NOTE: ``min_hnr_db=0.0`` sit 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 diff --git a/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py old mode 100644 new mode 100755 index b701d9f8b..b92b0095b --- a/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py +++ b/tests/unit/higgs_audio_v3/test_higgs_audio_v3.py @@ -7,6 +7,7 @@ without requiring the actual checkpoint or GPU. """ import asyncio +import json import time from collections import OrderedDict, defaultdict from types import SimpleNamespace @@ -1309,7 +1310,10 @@ class TestVoiceCloneReferenceCache: parent = tmp_path / "OmniVoice" subdir = parent / "audio_tokenizer" subdir.mkdir(parents=True) - (subdir / "config.json").write_text("{}", encoding="utf-8") + (subdir / "config.json").write_text( + json.dumps({"model_type": "higgs_audio_v2_tokenizer"}), + encoding="utf-8", + ) monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent)) assert tok._resolve_audio_tokenizer_dir() == str(subdir) @@ -1317,6 +1321,32 @@ class TestVoiceCloneReferenceCache: monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(subdir)) assert tok._resolve_audio_tokenizer_dir() == str(subdir) + def test_audio_tokenizer_dir_rejects_omnivoice_config(self, tmp_path, monkeypatch): + import huggingface_hub + import huggingface_hub.constants as hub_constants + + from vllm_omni.model_executor.models.higgs_audio_v2 import higgs_audio_v2_tokenizer as tok + + parent = tmp_path / "OmniVoice" + subdir = parent / "audio_tokenizer" + subdir.mkdir(parents=True) + (subdir / "config.json").write_text( + json.dumps({"model_type": "omnivoice"}), + encoding="utf-8", + ) + + monkeypatch.setenv("HIGGS_AUDIO_TOKENIZER_PATH", str(parent)) + monkeypatch.setenv("HIGGS_AUDIO_V2_TOKENIZER_PATH", "") + monkeypatch.setattr( + huggingface_hub, + "try_to_load_from_cache", + lambda **_: str(subdir / "config.json"), + ) + monkeypatch.setattr(hub_constants, "HF_HUB_CACHE", str(tmp_path / "empty_cache")) + + assert tok._normalize_audio_tokenizer_dir(str(parent)) is None + assert tok._resolve_audio_tokenizer_dir() is None + def test_higgs_v3_ref_code_cache_returns_clone(self): from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech diff --git a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py old mode 100644 new mode 100755 index 960f6cfef..c3f5024b3 --- a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py +++ b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py @@ -22,6 +22,7 @@ generation. from __future__ import annotations +import json import os import re from typing import Any @@ -170,14 +171,25 @@ _AUDIO_TOKENIZER_PATH_ENVS = ( ) +def _is_higgs_audio_tokenizer_config(config_path: str) -> bool: + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + except (OSError, json.JSONDecodeError): + return False + return config.get("model_type") == "higgs_audio_v2_tokenizer" + + def _normalize_audio_tokenizer_dir(path: str) -> str | None: if not path: return None expanded = os.path.abspath(os.path.expanduser(path)) - if os.path.isfile(os.path.join(expanded, "config.json")): + config_path = os.path.join(expanded, "config.json") + if os.path.isfile(config_path) and _is_higgs_audio_tokenizer_config(config_path): return expanded nested = os.path.join(expanded, _K2_OMNIVOICE_SUBDIR) - if os.path.isfile(os.path.join(nested, "config.json")): + nested_config_path = os.path.join(nested, "config.json") + if os.path.isfile(nested_config_path) and _is_higgs_audio_tokenizer_config(nested_config_path): return nested return None @@ -201,7 +213,9 @@ def _resolve_audio_tokenizer_dir() -> str | None: filename=f"{_K2_OMNIVOICE_SUBDIR}/config.json", ) if isinstance(cached_config, str) and os.path.isfile(cached_config): - return os.path.dirname(cached_config) + candidate = _normalize_audio_tokenizer_dir(os.path.dirname(cached_config)) + if candidate is not None: + return candidate from huggingface_hub.constants import HF_HUB_CACHE @@ -240,7 +254,9 @@ def _load_audio_tokenizer(): _K2_OMNIVOICE_REPO, allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"], ) - audio_tokenizer_dir = os.path.join(repo_path, _K2_OMNIVOICE_SUBDIR) + audio_tokenizer_dir = _normalize_audio_tokenizer_dir(repo_path) + if audio_tokenizer_dir is None: + raise RuntimeError(f"Downloaded {_K2_OMNIVOICE_REPO} does not contain a valid Higgs audio tokenizer") device = current_omni_platform.get_torch_device() model = HiggsAudioV2TokenizerModel.from_pretrained(audio_tokenizer_dir).to(device) return model.eval() From d365d3a69e6f5a0f9d232628122c7313c244fcc3 Mon Sep 17 00:00:00 2001 From: Yuanheng Zhao <54058983+yuanheng-zhao@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:09:12 +0800 Subject: [PATCH 036/101] [Refactor] Migrate Ming-flash-omni-2.0 Image-gen examples with model_extras (#4835) Signed-off-by: Yuanheng Signed-off-by: Yuanheng Zhao --- .../image_to_image/image_edit.py | 24 ++- .../ming_flash_omni/README.md | 42 ++++- .../text_to_image/text_to_image.py | 38 +++- .../online_serving/ming_flash_omni/README.md | 137 +++++++++----- .../run_curl_multimodal_generation.sh | 169 ------------------ recipes/inclusionAI/Ming-flash-omni-2.0.md | 124 +++++++++++++ tests/model_extras/test_model_extras.py | 96 ++++++++++ vllm_omni/model_extras/ming_flash_omni.py | 66 +++++++ vllm_omni/model_extras/registry.py | 18 ++ 9 files changed, 478 insertions(+), 236 deletions(-) delete mode 100755 examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh create mode 100644 vllm_omni/model_extras/ming_flash_omni.py diff --git a/examples/offline_inference/image_to_image/image_edit.py b/examples/offline_inference/image_to_image/image_edit.py index 299efb33b..12bd978a3 100644 --- a/examples/offline_inference/image_to_image/image_edit.py +++ b/examples/offline_inference/image_to_image/image_edit.py @@ -138,6 +138,15 @@ def parse_args() -> argparse.Namespace: required=True, help="Path(s) to input image file(s) (PNG, JPG, etc.). Can specify multiple images.", ) + parser.add_argument( + "--deploy-config", + type=str, + default=None, + help=( + "Path to a deploy YAML. Required for multi-stage image-edit pipelines " + "whose deploy config is not auto-loaded." + ), + ) parser.add_argument( "--prompt", type=str, @@ -349,7 +358,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--enforce-eager", action="store_true", - help="Disable torch.compile and force eager execution.", + default=None, + help=( + "Disable torch.compile and force eager execution. Left unset (None) " + "so it is only forwarded when explicitly given; " + "otherwise the per-stage deploy YAML value wins." + ), ) parser.add_argument( "--vae-use-slicing", @@ -468,7 +482,7 @@ def main(): } # Initialize Omni with appropriate pipeline - omni = Omni( + omni_kwargs: dict[str, Any] = dict( model=args.model, enable_layerwise_offload=args.enable_layerwise_offload, vae_use_slicing=args.vae_use_slicing, @@ -476,11 +490,15 @@ def main(): cache_backend=args.cache_backend, cache_config=cache_config, parallel_config=parallel_config, - enforce_eager=args.enforce_eager, enable_cpu_offload=args.enable_cpu_offload, enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler, profiler_config=args.profiler_config, ) + if args.enforce_eager is not None: + omni_kwargs["enforce_eager"] = args.enforce_eager + if args.deploy_config: + omni_kwargs["deploy_config"] = args.deploy_config + omni = Omni(**omni_kwargs) model_class_name = get_model_class_name(omni) declared_extra_body_params = get_extra_body_params(model_class_name) print("Pipeline loaded") diff --git a/examples/offline_inference/ming_flash_omni/README.md b/examples/offline_inference/ming_flash_omni/README.md index 7e7ab0a8f..e60efe88b 100644 --- a/examples/offline_inference/ming_flash_omni/README.md +++ b/examples/offline_inference/ming_flash_omni/README.md @@ -90,14 +90,40 @@ Pass `--deploy-config /path/to/your_deploy.yaml` to any of the commands above to ### Image generation (text-to-image / img2img) -The diffusion-stage image-generation path is currently only wired through -the online OpenAI-compatible chat endpoint (`/v1/chat/completions` with -`"modalities": ["image"]`). For payloads, optional knobs -(`steps`/`cfg`/`seed`/`byte5_text`/`negative_prompt`), and the img2img -reference-image flow, see the -[image-gen section in the online-serving README](../../online_serving/ming_flash_omni/README.md#image-generation-text-to-image). -`end2end.py` does not yet exercise the imagegen stage; that is tracked as -follow-up work. +Image generation is served through the standard task examples. +The diffusion-stage knobs are declared centrally in `vllm_omni/model_extras/ming_flash_omni.py` and routed via `--extra-body`. With deploy yaml assigned properly, the model can run through the shared example scripts like any other diffusion model: + +Text-to-image (offline): + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model Jonathan1909/Ming-flash-omni-2.0 \ + --deploy-config vllm_omni/deploy/ming_flash_omni_image.yaml \ + --prompt "Please draw a cute cat." \ + --height 1024 \ + --width 1024 \ + --extra-body '{"steps": 30, "cfg": 2.0, "seed": 42}' \ + --output ming_flash_omni_t2i.png +``` + +Image-to-image (offline): +```bash +# Reference image: figures/cases/person_gen_05.png from the upstream Ming repo +# https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/examples/vllm_demo.py +wget https://raw.githubusercontent.com/inclusionAI/Ming/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/figures/cases/person_gen_05.png + +python examples/offline_inference/image_to_image/image_edit.py \ + --model Jonathan1909/Ming-flash-omni-2.0 \ + --deploy-config vllm_omni/deploy/ming_flash_omni_image.yaml \ + --image person_gen_05.png \ + --prompt "Put a pair of sunglasses on the person." \ + --extra-args '{"steps": 30, "cfg": 2.0, "seed": 42}' \ + --output ming_flash_omni_i2i.png +``` + +For the online path and the full knob list +(`steps`/`cfg`/`seed`/`byte5_text`/`negative_prompt`/`height`/`width`), see the +[image-generation section in the recipe](../../../recipes/inclusionAI/Ming-flash-omni-2.0.md#image-generation-text-to-image--img2img). ## Online serving diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py index 69f9e4fcb..a2236d8ca 100644 --- a/examples/offline_inference/text_to_image/text_to_image.py +++ b/examples/offline_inference/text_to_image/text_to_image.py @@ -69,7 +69,16 @@ def parse_args() -> argparse.Namespace: "--stage-configs-path", type=str, default=None, - help="Path to a YAML file containing stage configurations for Omni.", + help="[Deprecated] Path to a legacy stage_args-format YAML. Prefer --deploy-config.", + ) + parser.add_argument( + "--deploy-config", + type=str, + default=None, + help=( + "Path to a deploy YAML (new pipeline/stages format). Required for multi-stage " + "text-to-image pipelines whose deploy config is not auto-loaded." + ), ) parser.add_argument("--prompt", default="a cup of coffee on the table", help="Text prompt for image generation.") parser.add_argument( @@ -155,7 +164,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--enforce-eager", action="store_true", - help="Disable torch.compile and force eager execution.", + default=None, + help=( + "Disable torch.compile and force eager execution. Left unset (None) " + "so it is only forwarded when explicitly given; " + "otherwise the per-stage deploy YAML value wins." + ), ) parser.add_argument( "--enable-cpu-offload", @@ -221,8 +235,13 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--tensor-parallel-size", type=int, - default=1, - help="Number of GPUs used for tensor parallelism (TP) inside the DiT.", + default=None, + help=( + "Number of GPUs used for tensor parallelism (TP) inside the DiT. " + "Left unset so it is only forwarded when explicitly given; " + "otherwise the per-stage deploy YAML (or engine default of 1) wins. " + "Passing it always overrides the deploy YAML." + ), ) parser.add_argument( "--enable-expert-parallel", @@ -410,10 +429,8 @@ def main(): "ring_degree": args.ring_degree, "ulysses_mode": args.ulysses_mode, "cfg_parallel_size": args.cfg_parallel_size, - "tensor_parallel_size": args.tensor_parallel_size, "vae_patch_parallel_size": args.vae_patch_parallel_size, "enable_expert_parallel": args.enable_expert_parallel, - "enforce_eager": args.enforce_eager, "enable_cpu_offload": args.enable_cpu_offload, "mode": "text-to-image", "log_stats": args.log_stats, @@ -425,8 +442,14 @@ def main(): **lora_args, **quant_kwargs, } + if args.tensor_parallel_size is not None: + omni_kwargs["tensor_parallel_size"] = args.tensor_parallel_size + if args.enforce_eager is not None: + omni_kwargs["enforce_eager"] = args.enforce_eager if args.stage_configs_path: omni_kwargs["stage_configs_path"] = args.stage_configs_path + if args.deploy_config: + omni_kwargs["deploy_config"] = args.deploy_config if use_nextstep: # NextStep-1.1 requires explicit pipeline class omni_kwargs["model_class_name"] = "NextStep11Pipeline" @@ -451,8 +474,9 @@ def main(): print(f" Quantization: {args.quantization if args.quantization else 'None (BF16)'}") if ignored_layers: print(f" Ignored layers: {ignored_layers}") + tp_display = args.tensor_parallel_size if args.tensor_parallel_size is not None else "deploy/default" print( - f" Parallel configuration: tensor_parallel_size={args.tensor_parallel_size}, " + f" Parallel configuration: tensor_parallel_size={tp_display}, " f"ulysses_degree={args.ulysses_degree}, ulysses_mode={args.ulysses_mode}, " f"ring_degree={args.ring_degree}, cfg_parallel_size={args.cfg_parallel_size}, " f"vae_patch_parallel_size={args.vae_patch_parallel_size}, " diff --git a/examples/online_serving/ming_flash_omni/README.md b/examples/online_serving/ming_flash_omni/README.md index d60ba8156..9810841db 100644 --- a/examples/online_serving/ming_flash_omni/README.md +++ b/examples/online_serving/ming_flash_omni/README.md @@ -49,22 +49,14 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene --modalities text ``` -Parameterized curl wrapper in this directory: -```bash -bash run_curl_multimodal_generation.sh text -bash run_curl_multimodal_generation.sh use_image -bash run_curl_multimodal_generation.sh use_audio -bash run_curl_multimodal_generation.sh use_video -bash run_curl_multimodal_generation.sh use_mixed_modalities -bash run_curl_multimodal_generation.sh use_image_gen -``` +## Image generation (text-to-image / img2img) -## Image generation (text-to-image) +Ming-flash-omni-2.0 also exposes an image-generation (diffusion) stage. Launch with the image deploy YAML, which adds an image-gen stage behind the thinker. -Ming-flash-omni-2.0 also exposes an image-generation (diffusion) stage. -Launch with the image deploy YAML, which adds an image-gen stage behind -the thinker: +The image-generation stage is a standard vLLM-Omni diffusion pipeline (`MingImagePipeline`); its request knobs are declared in `vllm_omni/model_extras/ming_flash_omni.py` and routed through `extra_body`, so they no longer need a bespoke `sampling_params_list` recipe (that form is still available for per-stage thinker sampling — see below). + +### Launch ```bash vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni \ @@ -74,32 +66,58 @@ vllm serve Jonathan1909/Ming-flash-omni-2.0 --omni \ --port 8091 ``` -Then request image output by passing `"modalities": ["image"]`: + +### Text-to-image + +Request image output with `"modalities": ["image"]`: ```bash curl -s http://127.0.0.1:8091/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{ - "model": "Jonathan1909/Ming-flash-omni-2.0", - "messages": [{"role": "user", "content": "Please draw a cute cat."}], - "modalities": ["image"] - }' | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' | base64 -d > ming_imagegen.png + -H "Content-Type: application/json" \ + -d '{ + "model": "Jonathan1909/Ming-flash-omni-2.0", + "messages": [ + { + "role": "user", + "content": "Please draw a cute cat." + } + ], + "modalities": ["image"] + }' \ + | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \ + | base64 -d > ming_imagegen.png ``` -### Optional knobs +Pass generation knobs under a literal `extra_body` object (the OpenAI Python client's `extra_body=` kwarg produces the same request). Keys are filtered against the declared set and routed into every stage's `extra_args`: -Pass image-gen overrides as flat keys on the diffusion-stage `sampling_params_list[1].extra_args`: +```bash +curl http://127.0.0.1:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Jonathan1909/Ming-flash-omni-2.0", + "modalities": ["image"], + "messages": [ + { + "role": "user", + "content": "Draw a poster." + } + ], + "extra_body": { + "steps": 6, + "cfg": 1.5, + "height": 512, + "width": 512, + "seed": 123, + "byte5_text": ["理解与生成统一"], + "negative_prompt": "ugly, blurry, distorted" + } + }' \ + | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \ + | base64 -d > ming_imagegen_extra_body.png +``` -| Key | Default | Description | -| --- | --- | --- | -| `height` / `width` | from config (1024) | Output resolution (multiples of `vae_scale_factor * 2`, currently 16). | -| `steps` | 30 | Number of FlowMatchEuler denoise steps. | -| `cfg` | 2.0 | Classifier-free guidance scale. | -| `seed` | 42 | Per-request RNG seed (deterministic when ByT5 is also seed-stable). | -| `byte5_text` | (auto) | Override the glyph text for ByT5 enhancement; raw strings are auto-wrapped to Ming's `Text "...". ` format. | -| `negative_prompt` | empty | Real CFG negative conditioning (set on **stage-0 thinker** `extra_args` so `expand_cfg_prompts` spawns the companion). | - -Example with all the knobs: +NOTE: `extra_body` does **not** set the thinker's own sampling params. To tune the thinker (stage-0) sampling (`temperature` / `top_p` / `top_k` / `max_tokens`) or place knobs explicitly per stage, use `sampling_params_list` (`[thinker, imagegen]`). +`negative_prompt` must sit on the **stage-0** entry to trigger the real-CFG companion; the imagegen knobs go on **stage-1**: ```bash curl http://127.0.0.1:8091/v1/chat/completions \ @@ -143,27 +161,48 @@ curl http://127.0.0.1:8091/v1/chat/completions \ ### img2img (reference image) -Add an `image_url` content part to the user message; the parser routes it -into the diffusion stage as `extra[reference_image]`: +Add an `image_url` content part (a base64 data URL) to the user message; it is routed into the DiT stage as `extra[reference_image]`. base64-encode a local file and stream it through `jq` via stdin - piping `base64 -> jq -> curl` so that avoids the shell `ARG_MAX` limit that inlining a large base64 string in `-d '…'` hits. -```jsonc -"messages": [{ - "role": "user", - "content": [ - {"type": "text", "text": "Change the background to a sandy beach at sunset."}, - {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}} - ] -}] +```bash +# Reference image: figures/cases/person_gen_05.png from the upstream Ming repo +# Check https://github.com/inclusionAI/Ming/blob/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/examples/vllm_demo.py +wget https://raw.githubusercontent.com/inclusionAI/Ming/3954fcb880ff5e61ff128bcf7f1ec344d46a6fe3/figures/cases/person_gen_05.png + +base64 -w0 person_gen_05.png \ +| jq -Rs --arg prompt "Put a pair of sunglasses on the person." '{ + model: "Jonathan1909/Ming-flash-omni-2.0", + modalities: ["image"], + messages: [ + { + role: "user", + content: [ + { type: "text", text: $prompt }, + { type: "image_url", image_url: { url: ("data:image/png;base64," + (. | rtrimstr("\n"))) } } + ] + } + ] + }' \ +| curl http://127.0.0.1:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d @- \ +| jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \ +| base64 -d > ming_img2img.png ``` -### GPU layout +The reference image can also be a public URL (`"url": "https://…/photo.jpg"`) or the simplified `{"image": ""}` content-part form — see the [image-to-image request formats](../image_to_image/README.md#request-format). -The shipped `ming_flash_omni_image.yaml` allocates the thinker on GPUs 0–3 -(TP=4) and the diffusion stage on GPU 4 (TP=1). Copy the YAML and edit -`devices` per stage to relocate; with fewer GPUs available, drop -the thinker TP to 2 and run the diffusion stage on a free card. Image-gen -warmup takes roughly an extra 30–60 s on top of the thinker — set -`--stage-init-timeout 1800` if the default 300 s is too tight. +### Knobs (declared `extra_body` params) + +| Key | Default | Description | +| --- | --- | --- | +| `height` / `width` | 1024 | Output resolution (multiples of `vae_scale_factor * 2`, currently 16). | +| `steps` | 30 | Number of FlowMatchEuler denoise steps. | +| `cfg` | 2.0 | Classifier-free guidance scale. | +| `seed` | 42 | Per-request RNG seed. | +| `byte5_text` | (auto) | Glyph text for ByT5 enhancement; raw strings are auto-wrapped to Ming's `Text "…". ` format. Auto-extracted from quoted spans in the prompt when omitted. | +| `negative_prompt` | (empty) | Real CFG negative conditioning (text-to-image only). | + +For the offline `text_to_image.py` / `image_edit.py` scripts and the full knob reference, see the [image-generation section in the recipe](../../../recipes/inclusionAI/Ming-flash-omni-2.0.md#image-generation-text-to-image--img2img). ## Modality control diff --git a/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh b/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh deleted file mode 100755 index 92bb32c47..000000000 --- a/examples/online_serving/ming_flash_omni/run_curl_multimodal_generation.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Server port -PORT="${PORT:-8091}" -# Default query type -QUERY_TYPE="${1:-text}" - -# Validate query type -if [[ ! "$QUERY_TYPE" =~ ^(text|use_audio|use_image|use_video|use_mixed_modalities|use_image_gen)$ ]]; then - echo "Error: Invalid query type '$QUERY_TYPE'" - echo "Usage: $0 [text|use_audio|use_image|use_video|use_mixed_modalities|use_image_gen]" - echo " text: Text-only query" - echo " use_audio: Audio + Text query" - echo " use_image: Image + Text query" - echo " use_video: Video + Text query" - echo " use_mixed_modalities: Audio + Image + Video + Text query" - echo " use_image_gen: Text-to-image (diffusion stage) — saves the generated PNG locally" - exit 1 -fi - -# Define URLs for assets -MARY_HAD_LAMB_AUDIO_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/mary_had_lamb.ogg" -CHERRY_BLOSSOM_IMAGE_URL="https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg" -SAMPLE_VIDEO_URL="https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4" - -# Build user content based on query type -case "$QUERY_TYPE" in - text) - user_content='[ - { - "type": "text", - "text": "请详细介绍鹦鹉的生活习性。" - } - ]' - ;; - use_image) - user_content='[ - { - "type": "image_url", - "image_url": { - "url": "'"$CHERRY_BLOSSOM_IMAGE_URL"'" - } - }, - { - "type": "text", - "text": "Describe this image in detail." - } - ]' - ;; - use_audio) - user_content='[ - { - "type": "audio_url", - "audio_url": { - "url": "'"$MARY_HAD_LAMB_AUDIO_URL"'" - } - }, - { - "type": "text", - "text": "Please recognize the language of this speech and transcribe it. Format: oral." - } - ]' - ;; - use_video) - user_content='[ - { - "type": "video_url", - "video_url": { - "url": "'"$SAMPLE_VIDEO_URL"'" - } - }, - { - "type": "text", - "text": "Describe what is happening in this video." - } - ]' - ;; - use_mixed_modalities) - user_content='[ - { - "type": "image_url", - "image_url": { - "url": "'"$CHERRY_BLOSSOM_IMAGE_URL"'" - } - }, - { - "type": "audio_url", - "audio_url": { - "url": "'"$MARY_HAD_LAMB_AUDIO_URL"'" - } - }, - { - "type": "text", - "text": "Describe the image, and recognize the language of this speech and transcribe it. Format: oral" - } - ]' - ;; -esac - -echo "Running query type: $QUERY_TYPE" -echo "" - -if [[ "$QUERY_TYPE" == "use_image_gen" ]]; then - # Image-gen branch: dual-stage diffusion endpoint, modalities=["image"]. - # The shipped ming_flash_omni_image.yaml runs the thinker on cards 0-3 - # (TP=4) and the diffusion stage on card 4 (TP=1). See the image-gen - # section in README.md for knobs and img2img. - out_path="${OUT_PATH:-/tmp/ming_imagegen.png}" - request_body=$(cat <<'EOF' -{ - "model": "Jonathan1909/Ming-flash-omni-2.0", - "modalities": ["image"], - "messages": [ - {"role": "user", "content": "Draw a beautiful girl with short black hair and red dress."} - ] -} -EOF -) - output=$(curl -sS --retry 3 --retry-delay 3 --retry-connrefused \ - -X POST "http://localhost:${PORT}/v1/chat/completions" \ - -H "Content-Type: application/json" \ - -d "$request_body") - # Server returns base64-encoded PNG either as a data URL in - # ``choices[0].message.content[0].image_url.url`` or as a raw string. - b64=$(echo "$output" | jq -r '.choices[0].message.content - | if type == "array" - then (map(select(.image_url? != null)) | .[0].image_url.url // "") - elif type == "string" then . else "" end') - if [[ -z "$b64" ]]; then - echo "Error: no image returned. Raw response:" - echo "$output" | jq '.' - exit 1 - fi - if [[ "$b64" == data:image* ]]; then b64="${b64#*,}"; fi - printf '%s' "$b64" | base64 --decode > "$out_path" - echo "Saved generated image: $out_path ($(stat -c%s "$out_path" 2>/dev/null || stat -f%z "$out_path") bytes)" - exit 0 -fi - -request_body=$(cat < ming_imagegen.png +``` + +**Quick form — `extra_body`** (keys are filtered against the declared set and routed into every stage's `extra_args`). Convenient, but it does **not** let you set the thinker (stage-0) sampling params. Wrap the knobs in a literal `"extra_body"` object for raw curl; the OpenAI Python client's `extra_body=` kwarg produces the same request: + +```bash +curl http://127.0.0.1:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Jonathan1909/Ming-flash-omni-2.0", + "modalities": ["image"], + "messages": [ + { + "role": "user", + "content": "Draw a poster." + } + ], + "extra_body": { + "steps": 6, + "cfg": 1.5, + "height": 512, + "width": 512, + "seed": 123, + "byte5_text": ["理解与生成统一"], + "negative_prompt": "ugly, blurry, distorted" + } + }' \ + | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \ + | base64 -d > ming_imagegen_extra_body.png +``` + +**Full control — `sampling_params_list`** (one entry per stage: `[thinker, imagegen]`). +Use this when you need to tune the thinker's own sampling (`temperature` / `top_p` / `top_k` / `max_tokens`), or to place knobs explicitly per stage. +Note `negative_prompt` must sit on the **stage-0 thinker** `extra_args`; the imagegen-stage knobs (`steps` / `cfg` / `height` / `width` / `seed` / `byte5_text`) go on the **stage-1** entry: + +```bash +curl http://127.0.0.1:8091/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "Jonathan1909/Ming-flash-omni-2.0", + "modalities": ["image"], + "sampling_params_list": [ + { + "temperature": 0.4, + "top_p": 0.9, + "top_k": 1, + "max_tokens": 1, + "seed": 42, + "extra_args": { + "negative_prompt": "ugly, blurry, distorted" + } + }, + { + "seed": 42, + "extra_args": { + "steps": 6, + "cfg": 1.5, + "height": 512, + "width": 512, + "seed": 123, + "byte5_text": ["理解与生成统一"] + } + } + ], + "messages": [ + { + "role": "user", + "content": "Draw a poster." + } + ] + }' \ + | jq -r '.choices[0].message.content[0].image_url.url | split(",")[1]' \ + | base64 -d > ming_imagegen_knobs.png +``` + +### Knobs (declared `extra_body` params) + +| Key | Default | Description | +| --- | --- | --- | +| `height` / `width` | 1024 | Output resolution (multiples of `vae_scale_factor * 2`, currently 16). | +| `steps` | 30 | Number of FlowMatchEuler denoise steps. | +| `cfg` | 2.0 | Classifier-free guidance scale. | +| `seed` | 42 | Per-request RNG seed. | +| `byte5_text` | (auto) | Glyph text for ByT5 enhancement; raw strings are auto-wrapped to Ming's `Text "…". ` format. Auto-extracted from quoted spans in the prompt when omitted. | +| `negative_prompt` | (empty) | Real CFG negative conditioning. Spawns a CFG-text companion via `expand_cfg_prompts`; **online / text-to-image only** (offline uses Ming's default zero-negative). | + +For img2img, add an `image_url` content part to the user message (online) or pass `--image` (offline); the reference image is routed into the DiT stage as `extra[reference_image]`. + ### 1x H100 80GB — standalone TTS (talker only) The bundled `ming_flash_omni_tts.yaml` runs the talker on a single GPU and exposes the OpenAI `/v1/audio/speech` endpoint. diff --git a/tests/model_extras/test_model_extras.py b/tests/model_extras/test_model_extras.py index f3a09346c..f2db9ded0 100644 --- a/tests/model_extras/test_model_extras.py +++ b/tests/model_extras/test_model_extras.py @@ -126,6 +126,102 @@ def test_magi_human_extra_registry_declares_request_and_response_params() -> Non assert should_init_extra_args_for_non_diffusion_stages("MagiHumanPipeline") is False +@pytest.mark.core_model +@pytest.mark.cpu +def test_ming_flash_omni_extra_registry_declares_request_and_response_params() -> None: + assert get_extra_body_params("MingImagePipeline") == frozenset( + { + "height", + "width", + "steps", + "cfg", + "seed", + "byte5_text", + "negative_prompt", + } + ) + assert get_extra_output_params("MingImagePipeline") == frozenset() + assert should_init_extra_args_for_non_diffusion_stages("MingImagePipeline") is True + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_ming_flash_omni_declared_extra_args_route_into_sampling_params() -> None: + params = OmniDiffusionSamplingParams() + declared = get_extra_body_params("MingImagePipeline") + apply_declared_extra_args( + params, + declared, + { + "steps": 6, + "cfg": 1.5, + "byte5_text": ["理解与生成统一"], + "negative_prompt": "ugly, blurry", + "unknown": "ignored", + }, + ) + assert params.extra_args == { + "steps": 6, + "cfg": 1.5, + "byte5_text": ["理解与生成统一"], + "negative_prompt": "ugly, blurry", + } + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_ming_flash_omni_text_to_image_prompt_builder() -> None: + assert build_text_to_image_prompt( + "MingImagePipeline", + prompt="Please draw a cute cat.", + negative_prompt="ugly, blurry", + height=512, + width=768, + ) == { + "prompt": "Please draw a cute cat.", + "modalities": ["image"], + "mm_processor_kwargs": { + "modalities": ["image"], + "target_h": 512, + "target_w": 768, + }, + "negative_prompt": "ugly, blurry", + } + # target_h/w are omitted when height/width are not supplied so the + # pipeline's 1024x1024 default still applies. + assert build_text_to_image_prompt( + "MingImagePipeline", + prompt="Draw a poster.", + negative_prompt=None, + ) == { + "prompt": "Draw a poster.", + "modalities": ["image"], + "mm_processor_kwargs": {"modalities": ["image"]}, + } + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_ming_flash_omni_image_to_image_prompt_builder() -> None: + dummy_image = Image.new("RGB", (64, 64)) + result = build_image_to_image_prompt( + "MingImagePipeline", + prompt="Change the background to a sandy beach at sunset.", + negative_prompt=None, + input_image=dummy_image, + height=256, + width=256, + ) + assert result["modalities"] == ["img2img"] + assert result["multi_modal_data"]["img2img"] is dummy_image + assert result["mm_processor_kwargs"] == { + "modalities": ["img2img"], + "target_h": 256, + "target_w": 256, + } + assert "negative_prompt" not in result + + @pytest.mark.core_model @pytest.mark.cpu def test_cosmos3_text_to_image_prompt_builder_selects_image_modality() -> None: diff --git a/vllm_omni/model_extras/ming_flash_omni.py b/vllm_omni/model_extras/ming_flash_omni.py new file mode 100644 index 000000000..8fece255d --- /dev/null +++ b/vllm_omni/model_extras/ming_flash_omni.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +from typing import Any + +from PIL import Image + + +def build_text_to_image_prompt( + prompt: str, + negative_prompt: str | None, + height: int | None = None, + width: int | None = None, +) -> dict[str, Any]: + text_prompt: dict[str, Any] = { + "prompt": prompt, + "modalities": ["image"], + "mm_processor_kwargs": {"modalities": ["image"]}, + } + if height is not None: + text_prompt["mm_processor_kwargs"]["target_h"] = height + if width is not None: + text_prompt["mm_processor_kwargs"]["target_w"] = width + if negative_prompt is not None: + text_prompt["negative_prompt"] = negative_prompt + return text_prompt + + +def build_image_to_image_prompt( + prompt: str, + negative_prompt: str | None, + input_image: Image.Image | list[Image.Image], + height: int | None = None, + width: int | None = None, +) -> dict[str, Any]: + img_prompt: dict[str, Any] = { + "prompt": prompt, + "modalities": ["img2img"], + "multi_modal_data": {"img2img": input_image}, + "mm_processor_kwargs": {"modalities": ["img2img"]}, + } + if height is not None: + img_prompt["mm_processor_kwargs"]["target_h"] = height + if width is not None: + img_prompt["mm_processor_kwargs"]["target_w"] = width + if negative_prompt is not None: + img_prompt["negative_prompt"] = negative_prompt + return img_prompt + + +# For Image-generation (diffusion-stage) +MING_FLASH_OMNI_EXTRA_BODY_PARAMS = frozenset( + { + "height", + "width", + "steps", + "cfg", + "seed", + "byte5_text", + "negative_prompt", + } +) +MING_FLASH_OMNI_EXTRA_OUTPUT_PARAMS = frozenset() +MING_FLASH_OMNI_INIT_EXTRA_ARGS_FOR_NON_DIFFUSION_STAGES = True diff --git a/vllm_omni/model_extras/registry.py b/vllm_omni/model_extras/registry.py index cec304213..7103991c7 100644 --- a/vllm_omni/model_extras/registry.py +++ b/vllm_omni/model_extras/registry.py @@ -46,6 +46,17 @@ from vllm_omni.model_extras.mammothmodal2_preview import ( from vllm_omni.model_extras.mammothmodal2_preview import ( build_text_to_image_prompt as build_mammothmoda2_text_to_image_prompt, ) +from vllm_omni.model_extras.ming_flash_omni import ( + MING_FLASH_OMNI_EXTRA_BODY_PARAMS, + MING_FLASH_OMNI_EXTRA_OUTPUT_PARAMS, + MING_FLASH_OMNI_INIT_EXTRA_ARGS_FOR_NON_DIFFUSION_STAGES, +) +from vllm_omni.model_extras.ming_flash_omni import ( + build_image_to_image_prompt as build_ming_flash_omni_image_to_image_prompt, +) +from vllm_omni.model_extras.ming_flash_omni import ( + build_text_to_image_prompt as build_ming_flash_omni_text_to_image_prompt, +) from vllm_omni.model_extras.sensenova_u1 import ( SENSENOVA_U1_EXTRA_BODY_PARAMS, SENSENOVA_U1_EXTRA_OUTPUT_PARAMS, @@ -165,6 +176,13 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = { "init_extra_args_for_non_diffusion_stages": MAMMOTHMODA2_PREVIEW_INIT_EXTRA_ARGS_FOR_NON_DIFFUSION_STAGES, "text_to_image_prompt_builder": build_mammothmoda2_text_to_image_prompt, }, + "MingImagePipeline": { + "extra_body_params": MING_FLASH_OMNI_EXTRA_BODY_PARAMS, + "extra_output_params": MING_FLASH_OMNI_EXTRA_OUTPUT_PARAMS, + "init_extra_args_for_non_diffusion_stages": MING_FLASH_OMNI_INIT_EXTRA_ARGS_FOR_NON_DIFFUSION_STAGES, + "text_to_image_prompt_builder": build_ming_flash_omni_text_to_image_prompt, + "image_to_image_prompt_builder": build_ming_flash_omni_image_to_image_prompt, + }, } From 31c9c751fc1d85c78b9ea89c1c4ce649b61026ad Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Tue, 7 Jul 2026 17:32:32 +0800 Subject: [PATCH 037/101] [Bugfix] Fix LoRA arguments passing in offline text-to-image script (#4936) Signed-off-by: SamitHuang <285365963@qq.com> Co-authored-by: Cursor --- examples/offline_inference/text_to_image/text_to_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py index a2236d8ca..a8d980c6f 100644 --- a/examples/offline_inference/text_to_image/text_to_image.py +++ b/examples/offline_inference/text_to_image/text_to_image.py @@ -546,8 +546,8 @@ def main(): diffusion_params.extra_args.update({k: v for k, v in user_extra.items() if v is not None}) if lora_request: - diffusion_params.extra_args["lora_request"] = lora_request - diffusion_params.extra_args["lora_scale"] = args.lora_scale + diffusion_params.lora_request = lora_request + diffusion_params.lora_scale = args.lora_scale # Build per-stage sampling params for multi-stage models (e.g. BAGEL), # or wrap single diffusion params for single-stage models. From 815abce2bad2cee2f4e762f83852a108af8d44c0 Mon Sep 17 00:00:00 2001 From: Ting FU Date: Tue, 7 Jul 2026 17:54:39 +0800 Subject: [PATCH 038/101] [BugFix] fix possible shape mismatch when using ROPE (#4655) Signed-off-by: Semmer --- tests/diffusion/layers/test_rope_broadcast.py | 236 ++++++++++++++++++ vllm_omni/diffusion/layers/rope.py | 6 + 2 files changed, 242 insertions(+) create mode 100644 tests/diffusion/layers/test_rope_broadcast.py diff --git a/tests/diffusion/layers/test_rope_broadcast.py b/tests/diffusion/layers/test_rope_broadcast.py new file mode 100644 index 000000000..29fcf1804 --- /dev/null +++ b/tests/diffusion/layers/test_rope_broadcast.py @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Shape broadcasting regression tests for RotaryEmbedding.forward_native. + +Covers the batch-dimension shape mismatch fix: forward_native now strips the +batch dim from 3D cos/sin before applying RoPE, consistent with forward_cuda, +forward_hip, and apply_rotary_emb_mindiesd paths. +""" + +from __future__ import annotations + +import pytest +import torch + +from vllm_omni.diffusion.layers.rope import RotaryEmbedding + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_inputs( + batch: int, + seq_len: int, + num_heads: int, + head_dim: int, + cos_batch: int | None = None, + dtype: torch.dtype = torch.float32, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Generate x, cos, sin tensors for RoPE testing. + + Args: + batch: Batch dimension of x. + seq_len: Sequence length. + num_heads: Number of attention heads. + head_dim: Head dimension (must be even). + cos_batch: Batch dimension of cos/sin. Defaults to batch if None. + dtype: Data type for the tensors. + """ + if cos_batch is None: + cos_batch = batch + + torch.manual_seed(42) + x = torch.randn(batch, seq_len, num_heads, head_dim, dtype=dtype) + cos = torch.randn(cos_batch, seq_len, head_dim // 2, dtype=dtype) + sin = torch.randn(cos_batch, seq_len, head_dim // 2, dtype=dtype) + return x, cos, sin + + +class TestRotaryEmbeddingNativeShapeRegression: + """Regression tests for forward_native shape handling (#8e297d81).""" + + @pytest.fixture + def rope_neox(self) -> RotaryEmbedding: + """NeoX-style (non-interleaved) RoPE.""" + return RotaryEmbedding(is_neox_style=True) + + @pytest.fixture + def rope_gptj(self) -> RotaryEmbedding: + """GPT-J style (interleaved) RoPE.""" + return RotaryEmbedding(is_neox_style=False) + + # ── 2D cos/sin (no batch dim) ────────────────────────────────────── + + def test_2d_cos_sin_unchanged(self, rope_neox: RotaryEmbedding) -> None: + """2D cos/sin [S, D/2] pass through unchanged (no-op path).""" + x, cos, sin = _make_inputs(batch=2, seq_len=16, num_heads=8, head_dim=64, cos_batch=2) + # Manually drop batch dim to create 2D cos/sin + cos_2d = cos[0] + sin_2d = sin[0] + assert cos_2d.dim() == 2 + output = rope_neox.forward_native(x, cos_2d, sin_2d) + assert output.shape == x.shape + assert not torch.isnan(output).any() + assert not torch.isinf(output).any() + + # ── 3D cos/sin — batch-dim stripping ────────────────────────────── + + @pytest.mark.parametrize("cos_batch", [1, 2, 4]) + def test_3d_cos_sin_no_error(self, rope_neox: RotaryEmbedding, cos_batch: int) -> None: + """3D cos/sin with batch dim should not raise errors.""" + x, cos, sin = _make_inputs(batch=2, seq_len=16, num_heads=8, head_dim=64, cos_batch=cos_batch) + output = rope_neox.forward_native(x, cos, sin) + assert output.shape == x.shape + + @pytest.mark.parametrize("interleaved", [False, True]) + def test_3d_cos_identical_to_2d(self, interleaved: bool) -> None: + """3D cos/sin with identical batch slices == 2D cos/sin result.""" + rope = RotaryEmbedding(is_neox_style=not interleaved) + B, S, H, D = 2, 16, 8, 64 + x = torch.randn(B, S, H, D) + cos_2d = torch.randn(S, D // 2) + sin_2d = torch.randn(S, D // 2) + + # 3D variant: expand the same 2D data across batch + cos_3d = cos_2d.unsqueeze(0).expand(B, -1, -1) + sin_3d = sin_2d.unsqueeze(0).expand(B, -1, -1) + + out_2d = rope.forward_native(x, cos_2d, sin_2d) + out_3d = rope.forward_native(x, cos_3d, sin_3d) + + torch.testing.assert_close(out_3d, out_2d, atol=0, rtol=0) + + # ── Batch size mismatch — the regression scenario ───────────────── + + @pytest.mark.parametrize( + "x_batch,cos_batch", + [ + (2, 1), # cos has fewer batch elements + (1, 2), # cos has more batch elements (takes [0]) + (4, 1), # large x batch, single cos + ], + ) + def test_batch_size_mismatch_regression( + self, + rope_neox: RotaryEmbedding, + x_batch: int, + cos_batch: int, + ) -> None: + """forward_native works when cos batch dim differs from x batch dim. + + This is the exact regression scenario — before the fix, a 3D cos/sin + with a different batch size than x would cause a shape mismatch error + inside apply_rotary_emb_torch during broadcast. + """ + x, cos, sin = _make_inputs(batch=x_batch, seq_len=16, num_heads=8, head_dim=64, cos_batch=cos_batch) + output = rope_neox.forward_native(x, cos, sin) + assert output.shape == x.shape + assert output.dtype == x.dtype + + # ── Batch consistency — all elements get same rotation ──────────── + + def test_2d_cos_broadcasts_identically_across_batch(self, rope_neox: RotaryEmbedding) -> None: + """With 2D cos, identical x slices → identical output slices.""" + B, S, H, D = 4, 16, 8, 64 + x_template = torch.randn(1, S, H, D) + x = x_template.expand(B, -1, -1, -1) # all batch elements identical + cos = torch.randn(S, D // 2) + sin = torch.randn(S, D // 2) + + output = rope_neox.forward_native(x, cos, sin) + + # All output batch slices must be identical + for i in range(1, B): + torch.testing.assert_close(output[i], output[0], atol=0, rtol=0) + + def test_3d_identical_cos_broadcasts_identically_across_batch(self, rope_neox: RotaryEmbedding) -> None: + """With 3D cos (identical slices), identical x → identical output.""" + B, S, H, D = 4, 16, 8, 64 + x_template = torch.randn(1, S, H, D) + x = x_template.expand(B, -1, -1, -1) + cos = torch.randn(1, S, D // 2).expand(B, -1, -1) + sin = torch.randn(1, S, D // 2).expand(B, -1, -1) + + output = rope_neox.forward_native(x, cos, sin) + + for i in range(1, B): + torch.testing.assert_close(output[i], output[0], atol=0, rtol=0) + + # ── Output shape / dtype invariants ─────────────────────────────── + + def test_output_shape_preserved(self, rope_neox: RotaryEmbedding) -> None: + """Output shape must match input shape for all cos/sin dimensionalities.""" + B, S, H, D = 2, 32, 4, 128 + x = torch.randn(B, S, H, D) + + # 2D cos/sin + cos_2d = torch.randn(S, D // 2) + sin_2d = torch.randn(S, D // 2) + assert rope_neox.forward_native(x, cos_2d, sin_2d).shape == (B, S, H, D) + + # 3D cos/sin, matching batch + cos_3d = torch.randn(B, S, D // 2) + sin_3d = torch.randn(B, S, D // 2) + assert rope_neox.forward_native(x, cos_3d, sin_3d).shape == (B, S, H, D) + + # 3D cos/sin, different batch + cos_3d_1 = torch.randn(1, S, D // 2) + sin_3d_1 = torch.randn(1, S, D // 2) + assert rope_neox.forward_native(x, cos_3d_1, sin_3d_1).shape == (B, S, H, D) + + def test_output_dtype_preserved(self, rope_neox: RotaryEmbedding) -> None: + """Output dtype must match input dtype.""" + for dtype in [torch.float32, torch.float16, torch.bfloat16]: + x, cos, sin = _make_inputs(batch=2, seq_len=16, num_heads=8, head_dim=64, dtype=dtype) + output = rope_neox.forward_native(x, cos, sin) + assert output.dtype == dtype, f"dtype mismatch: {output.dtype} != {dtype}" + + def test_output_no_nan_inf( + self, + rope_neox: RotaryEmbedding, + rope_gptj: RotaryEmbedding, + ) -> None: + """Output must be finite for both RoPE styles.""" + for rope in (rope_neox, rope_gptj): + x, cos, sin = _make_inputs(batch=2, seq_len=32, num_heads=4, head_dim=64) + output = rope.forward_native(x, cos, sin) + assert not torch.isnan(output).any(), f"NaN in {rope}" + assert not torch.isinf(output).any(), f"Inf in {rope}" + + def test_output_contiguous(self, rope_neox: RotaryEmbedding) -> None: + """Output should be contiguous.""" + x, cos, sin = _make_inputs(batch=2, seq_len=16, num_heads=8, head_dim=64) + output = rope_neox.forward_native(x, cos, sin) + assert output.is_contiguous() + + # ── CFG-style doubled batch ─────────────────────────────────────── + + def test_cfg_style_doubled_batch(self, rope_neox: RotaryEmbedding) -> None: + """CFG (classifier-free guidance) doubles batch — cos may have + original batch size while x has 2× batch.""" + B, S, H, D = 2, 16, 8, 64 + # Simulate CFG: x has 2× batch (cond + uncond concatenated) + x = torch.randn(B * 2, S, H, D) + # cos/sin only have the original batch + cos = torch.randn(B, S, D // 2) + sin = torch.randn(B, S, D // 2) + + output = rope_neox.forward_native(x, cos, sin) + assert output.shape == (B * 2, S, H, D) + + # ── RotaryEmbeddingWan forward_native is unchanged ──────────────── + + def test_wan_forward_native_unchanged(self) -> None: + """RotaryEmbeddingWan.forward_native still uses its own implementation + (unflatten + stack + flatten) and should not crash with 3D cos/sin.""" + from vllm_omni.diffusion.layers.rope import RotaryEmbeddingWan + + rope = RotaryEmbeddingWan(is_neox_style=False, half_head_dim=True) + B, S, H, D = 2, 16, 8, 64 + x = torch.randn(B, S, H, D) + cos = torch.randn(B, S, 1, D // 2) + sin = torch.randn(B, S, 1, D // 2) + + output = rope.forward_native(x, cos, sin) + assert output.shape == x.shape + assert not torch.isnan(output).any() diff --git a/vllm_omni/diffusion/layers/rope.py b/vllm_omni/diffusion/layers/rope.py index 4cb714465..6a045fca2 100644 --- a/vllm_omni/diffusion/layers/rope.py +++ b/vllm_omni/diffusion/layers/rope.py @@ -188,6 +188,12 @@ class RotaryEmbedding(CustomOp): cos: torch.Tensor, sin: torch.Tensor, ) -> torch.Tensor: + # All batch elements share the same rotary position encoding. + # Strip the batch dim so the underlying op broadcasts over the batch, + # consistent with forward_cuda / forward_hip / apply_rotary_emb_mindiesd. + if cos.dim() == 3: + cos = cos[0] + sin = sin[0] return apply_rotary_emb_torch( x, cos, From 7ab50965e8d3e64507d0504d706368f7e8318cf5 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:27:46 +0800 Subject: [PATCH 039/101] Update WeChat group QR code (#4939) --- docs/assets/WeChat.jpg | Bin 410802 -> 272842 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index 509eb9ef5b26e3c065f64fe1a1d1661ff8413d3f..5913183e937f6a5b5bc5b9e562364c4fabaa7023 100644 GIT binary patch literal 272842 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

&zF*J^WTW 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З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 z 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 literal 410802 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�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

eOf>p*py2%XHS#n&Ce>*bC2RjQ> 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 From 460a5c6b48b276106f5012167daec25058780829 Mon Sep 17 00:00:00 2001 From: Yukim1 <121286183+zwhzzz0821@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:44:33 +0800 Subject: [PATCH 040/101] [Refactor] Migrate MammothModa2 and OmniVoice to pipeline registry (#4647) Signed-off-by: Advik Signed-off-by: Nightwing-77 Signed-off-by: zwhzzz0821 <2831474076@qq.com> Co-authored-by: Advik --- .../mammothmodal2_preview.md | 2 +- .../mammothmodal2_preview/README.md | 2 +- .../run_mammothmoda2_image_summarize.py | 8 +-- .../text_to_speech/omnivoice/end2end.py | 8 +-- recipes/MammothModa2/MammothModa2-Preview.md | 12 ++-- .../test_mammoth_moda2_expansion.py | 6 +- .../test_omnivoice_expansion.py | 8 ++- tests/test_config_factory.py | 26 ++++++++ vllm_omni/config/pipeline_registry.py | 8 +++ vllm_omni/deploy/mammoth_moda2.yaml | 61 +++++++---------- vllm_omni/deploy/mammoth_moda2_ar.yaml | 14 ++++ vllm_omni/deploy/omnivoice.yaml | 24 +++---- .../pipeline_mammothmoda2_dit.py | 19 +++--- .../models/mammoth_moda2/pipeline.py | 66 +++++++++++++++++++ .../models/omnivoice/pipeline.py | 31 +++++++++ .../stage_configs/mammoth_moda2.yaml | 51 -------------- .../stage_configs/mammoth_moda2_ar.yaml | 24 ------- 17 files changed, 211 insertions(+), 159 deletions(-) create mode 100644 vllm_omni/deploy/mammoth_moda2_ar.yaml create mode 100644 vllm_omni/model_executor/models/mammoth_moda2/pipeline.py create mode 100644 vllm_omni/model_executor/models/omnivoice/pipeline.py delete mode 100644 vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml delete mode 100644 vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml diff --git a/docs/user_guide/examples/offline_inference/mammothmodal2_preview.md b/docs/user_guide/examples/offline_inference/mammothmodal2_preview.md index 6ab31298c..60f1cbe5f 100644 --- a/docs/user_guide/examples/offline_inference/mammothmodal2_preview.md +++ b/docs/user_guide/examples/offline_inference/mammothmodal2_preview.md @@ -22,7 +22,7 @@ Text-to-image now runs through the shared offline image example ```bash python examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py \ --model ./MammothModa2-Preview \ - --stage-config ./vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml \ + --deploy-config ./vllm_omni/deploy/mammoth_moda2_ar.yaml \ --question "Summarize this image." \ --image ./image.png ``` diff --git a/examples/offline_inference/mammothmodal2_preview/README.md b/examples/offline_inference/mammothmodal2_preview/README.md index 16937680a..bb68f3845 100644 --- a/examples/offline_inference/mammothmodal2_preview/README.md +++ b/examples/offline_inference/mammothmodal2_preview/README.md @@ -19,7 +19,7 @@ hf download bytedance-research/MammothModa2-Preview --local-dir ./MammothModa2-P ```bash python examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py \ --model ./MammothModa2-Preview \ - --stage-config ./vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml \ + --deploy-config ./vllm_omni/deploy/mammoth_moda2_ar.yaml \ --question "Summarize this image." \ --image ./image.png ``` diff --git a/examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py b/examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py index ca87b9e9a..7a22803c9 100644 --- a/examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py +++ b/examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py @@ -4,7 +4,7 @@ Offline inference example: MammothModa2 image summarization (single AR stage). Example: uv run python examples/offline_inference/mammothmodal2_preview/run_mammothmoda2_image_summarize.py \ --model path/to/MammothModa2-Preview \ - --stage-config vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml \ + --deploy-config vllm_omni/deploy/mammoth_moda2_ar.yaml \ --image /path/to/input.jpg \ --question "Please summarize the content of this image." """ @@ -27,9 +27,7 @@ DEFAULT_QUESTION = "Please summarize the content of this image." def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="MammothModa2 image summarization (offline, AR only).") parser.add_argument("--model", type=str, required=True, help="Path to model directory or model id.") - parser.add_argument( - "--stage-config", type=str, required=True, help="Path to stage config yaml (single-stage AR->text)." - ) + parser.add_argument("--deploy-config", type=str, required=True, help="Path to deploy config yaml.") parser.add_argument("--image", type=str, required=True, help="Path to input image.") parser.add_argument("--question", type=str, default=DEFAULT_QUESTION, help="Question/instruction for the model.") parser.add_argument("--system", type=str, default=DEFAULT_SYSTEM, help="System prompt.") @@ -73,7 +71,7 @@ def main() -> None: omni = Omni( model=args.model, - stage_configs_path=args.stage_config, + deploy_config=args.deploy_config, trust_remote_code=args.trust_remote_code, enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler, ) diff --git a/examples/offline_inference/text_to_speech/omnivoice/end2end.py b/examples/offline_inference/text_to_speech/omnivoice/end2end.py index 619f8160b..a6bdc7b4e 100644 --- a/examples/offline_inference/text_to_speech/omnivoice/end2end.py +++ b/examples/offline_inference/text_to_speech/omnivoice/end2end.py @@ -34,7 +34,7 @@ def run_e2e(): help="Model name or path (HuggingFace or local)", ) parser.add_argument( - "--stage-config", + "--deploy-config", type=str, default="vllm_omni/deploy/omnivoice.yaml", ) @@ -87,14 +87,14 @@ def run_e2e(): ) args = parser.parse_args() - if not os.path.exists(args.stage_config): - raise FileNotFoundError(f"Stage config not found: {args.stage_config}") + if not os.path.exists(args.deploy_config): + raise FileNotFoundError(f"Deploy config not found: {args.deploy_config}") print(f"Initializing OmniVoice with model={args.model}") omni = Omni( model=args.model, - stage_configs_path=args.stage_config, + deploy_config=args.deploy_config, log_stats=True, ) diff --git a/recipes/MammothModa2/MammothModa2-Preview.md b/recipes/MammothModa2/MammothModa2-Preview.md index 289efece0..0733b0630 100644 --- a/recipes/MammothModa2/MammothModa2-Preview.md +++ b/recipes/MammothModa2/MammothModa2-Preview.md @@ -32,12 +32,12 @@ flags. Image size uses the standard `--height` / `--width` flags. [`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py) - Declared parameters: [`vllm_omni/model_extras/mammothmodal2_preview.py`](../../vllm_omni/model_extras/mammothmodal2_preview.py) -- Stage config: - [`vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml`](../../vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml) +- Deploy config: + [`vllm_omni/deploy/mammoth_moda2.yaml`](../../vllm_omni/deploy/mammoth_moda2.yaml) ## Hardware Support -The default stage config runs both the AR and DiT stages on a single GPU +The default deploy config runs both the AR and DiT stages on a single GPU (`devices: "0"`). The committed `gpu_memory_utilization` split (stage-0 AR `0.5`, stage-1 DiT `0.3`) is sized for an ~80 GB GPU. The model also fits on a 48 GB GPU after rebalancing the split so the AR weights (~23 GB) leave room for the KV @@ -48,7 +48,7 @@ cache — see the note under *1x L40S 48GB*. ### 1x L40S 48GB > **48 GB config adjustment:** the committed -> `vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml` uses +> `vllm_omni/deploy/mammoth_moda2.yaml` uses > `gpu_memory_utilization` 0.5 / 0.3 (sized for ~80 GB). To fit on a 48 GB L40S, > set the stage-0 (AR) value to `0.8` and the stage-1 (DiT) value to `0.16` > before running. (On an ~80 GB GPU, leave the defaults unchanged.) @@ -71,13 +71,13 @@ hf download bytedance-research/MammothModa2-Preview --local-dir ./MammothModa2-P ``` Run text-to-image with the shared offline example from the repository root. The -stage config sets `trust_remote_code`, so no extra flag is needed. Forward the +deploy config sets `trust_remote_code`, so no extra flag is needed. Forward the MammothModa2 generation parameters as a JSON object through `--extra-body`: ```bash python examples/offline_inference/text_to_image/text_to_image.py \ --model ./MammothModa2-Preview \ - --stage-configs-path vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml \ + --stage-configs-path vllm_omni/deploy/mammoth_moda2.yaml \ --prompt "A stylish woman riding a motorcycle in NYC, movie poster style" \ --height 1024 \ --width 1024 \ diff --git a/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py b/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py index b0d82dbc4..04308c4a7 100644 --- a/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py +++ b/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py @@ -5,7 +5,7 @@ Verifies that the AR->DiT pipeline produces an image tensor whose pixel values match a golden reference. Model Hub repo id: ``bytedance-research/MammothModa2-Preview``. -Stage config: ``get_deploy_config_path("mammoth_moda2.yaml")`` → ``vllm_omni/deploy/mammoth_moda2.yaml`` +Deploy config: ``get_deploy_config_path("mammoth_moda2.yaml")`` -> ``vllm_omni/deploy/mammoth_moda2.yaml`` Golden pixel file: ``tests/e2e/offline_inference/fixtures/mammoth_moda2_t2i_golden.json`` Regenerate with: ``UPDATE_GOLDEN=1 pytest tests/e2e/offline_inference/test_mammoth_moda2_expansion.py`` @@ -36,9 +36,9 @@ _VISION_END_TOKEN_ID = 151653 # "<|vision_end|>" _AR_PATCH_SIZE = 16 MODEL_PATH = "bytedance-research/MammothModa2-Preview" -T2I_STAGE_CONFIG = get_deploy_config_path("mammoth_moda2.yaml") +T2I_DEPLOY_CONFIG = get_deploy_config_path("mammoth_moda2.yaml") -_OMNI_RUNNER_PARAM = (MODEL_PATH, T2I_STAGE_CONFIG) +_OMNI_RUNNER_PARAM = (MODEL_PATH, None, {"deploy_config": T2I_DEPLOY_CONFIG}) # Golden pixel reference file. Set UPDATE_GOLDEN=1 to regenerate. _GOLDEN_T2I_PATH = Path(__file__).parent / "fixtures" / "mammoth_moda2_t2i_golden.json" diff --git a/tests/e2e/offline_inference/test_omnivoice_expansion.py b/tests/e2e/offline_inference/test_omnivoice_expansion.py index 692278ef7..e3a138112 100644 --- a/tests/e2e/offline_inference/test_omnivoice_expansion.py +++ b/tests/e2e/offline_inference/test_omnivoice_expansion.py @@ -18,13 +18,15 @@ from tests.helpers.runtime import OmniRunner from tests.helpers.stage_config import get_deploy_config_path MODEL = "k2-fsa/OmniVoice" -STAGE_CONFIG = get_deploy_config_path("omnivoice.yaml") +DEPLOY_CONFIG = get_deploy_config_path("omnivoice.yaml") -# (model, stage_config_path, extra_omni_kwargs) — see ``omni_runner`` in tests.helpers.fixtures.runtime +# OmniRunner tuple: model, legacy stage config path, extra Omni kwargs. +# The migrated test passes deploy_config through extra Omni kwargs. _OMNI_RUNNER_PARAM = ( MODEL, - STAGE_CONFIG, + None, { + "deploy_config": DEPLOY_CONFIG, "trust_remote_code": True, "log_stats": True, }, diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 596a13dbb..717b9a5d7 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -749,6 +749,32 @@ stages: runtime_config = deploy.stages[0].engine_extras["hf_overrides"]["voxcpm2_runtime_config"] assert runtime_config == expected_runtime_config + @pytest.mark.parametrize( + ("deploy_name", "pipeline_name", "stage_count", "final_output_type"), + [ + ("mammoth_moda2.yaml", "mammoth_moda2", 2, "image"), + ("mammoth_moda2_ar.yaml", "mammoth_moda2_ar", 1, "text"), + ("omnivoice.yaml", "omnivoice", 1, "audio"), + ], + ) + def test_load_new_registry_backed_deploy_configs( + self, + deploy_name: str, + pipeline_name: str, + stage_count: int, + final_output_type: str, + ): + deploy_path = Path(get_deploy_config_path(deploy_name)) + deploy = load_deploy_config(deploy_path) + assert deploy.pipeline == pipeline_name + + with patch("vllm_omni.platforms.current_omni_platform") as platform: + platform.device_name = "cuda" + stages = merge_pipeline_deploy(OMNI_PIPELINES[pipeline_name], deploy) + assert len(stages) == stage_count + assert stages[-1].final_output is True + assert stages[-1].final_output_type == final_output_type + def test_merge_pipeline_deploy(self): deploy_path = Path(__file__).parent.parent / "vllm_omni" / "deploy" / "qwen3_omni_moe.yaml" if not deploy_path.exists(): diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 7bf79d44c..934d15d18 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -56,6 +56,10 @@ from vllm_omni.model_executor.models.hunyuan_image3.pipeline import ( ) from vllm_omni.model_executor.models.indextts2.pipeline import INDEXTTS2_PIPELINE from vllm_omni.model_executor.models.lance.pipeline import LANCE_PIPELINE +from vllm_omni.model_executor.models.mammoth_moda2.pipeline import ( + MAMMOTH_MODA2_AR_PIPELINE, + MAMMOTH_MODA2_PIPELINE, +) from vllm_omni.model_executor.models.mimo_audio.pipeline import MIMO_AUDIO_PIPELINE from vllm_omni.model_executor.models.ming_flash_omni.pipeline import ( MING_FLASH_OMNI_IMAGE_PIPELINE, @@ -74,6 +78,7 @@ from vllm_omni.model_executor.models.moss_tts.pipeline import ( MOSS_TTS_REALTIME_PIPELINE, ) from vllm_omni.model_executor.models.moss_tts_nano.pipeline import MOSS_TTS_NANO_PIPELINE +from vllm_omni.model_executor.models.omnivoice.pipeline import OMNIVOICE_PIPELINE from vllm_omni.model_executor.models.qwen2_5_omni.pipeline import ( QWEN2_5_OMNI_PIPELINE, QWEN2_5_OMNI_THINKER_ONLY_PIPELINE, @@ -118,6 +123,9 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = { "ming_flash_omni_thinker_only": MING_FLASH_OMNI_THINKER_ONLY_PIPELINE, "ming_flash_omni_image": MING_FLASH_OMNI_IMAGE_PIPELINE, "moss_tts_nano": MOSS_TTS_NANO_PIPELINE, + "omnivoice": OMNIVOICE_PIPELINE, + "mammoth_moda2": MAMMOTH_MODA2_PIPELINE, + "mammoth_moda2_ar": MAMMOTH_MODA2_AR_PIPELINE, "moss_tts_delay": MOSS_TTS_PIPELINE, "moss_tts_realtime": MOSS_TTS_REALTIME_PIPELINE, "moss_tts_local": MOSS_TTS_LOCAL_PIPELINE, diff --git a/vllm_omni/deploy/mammoth_moda2.yaml b/vllm_omni/deploy/mammoth_moda2.yaml index d1a49d662..afae38ee0 100644 --- a/vllm_omni/deploy/mammoth_moda2.yaml +++ b/vllm_omni/deploy/mammoth_moda2.yaml @@ -1,42 +1,27 @@ -# MammothModa2 AR→DiT (text-to-image). Canonical copy lives at -# ``vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml``; keep in sync. -# -# Must remain legacy ``stage_args`` layout (no top-level ``stages``): Omni resolves -# explicit ``--stage-configs-path`` via ``load_stage_configs_from_yaml``. +# MammothModa2 AR→DiT (text-to-image). -stage_args: +async_chunk: false +pipeline: mammoth_moda2 + +stages: - stage_id: 0 - runtime: - devices: "0" - engine_args: - model_stage: ar - max_num_seqs: 100 - model_arch: MammothModa2ForConditionalGeneration - worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - max_model_len: 8192 - gpu_memory_utilization: 0.5 - enforce_eager: true - trust_remote_code: true - engine_output_type: latent - enable_prefix_caching: false - final_output: false + devices: "0" + max_num_seqs: 100 + max_model_len: 8192 + gpu_memory_utilization: 0.5 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false - stage_id: 1 - runtime: - devices: "0" - engine_args: - model_stage: dit - max_num_seqs: 1 - model_arch: MammothModa2ForConditionalGeneration - worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker - scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler - gpu_memory_utilization: 0.3 - enforce_eager: true - trust_remote_code: true - engine_output_type: image - enable_prefix_caching: false - engine_input_source: [0] - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mammoth_moda2.ar2dit - final_output: true - final_output_type: image + devices: "0" + max_num_seqs: 1 + gpu_memory_utilization: 0.3 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false + default_sampling_params: + extra_args: + text_guidance_scale: 9.0 + cfg_range: [0.0, 1.0] + num_inference_steps: 50 diff --git a/vllm_omni/deploy/mammoth_moda2_ar.yaml b/vllm_omni/deploy/mammoth_moda2_ar.yaml new file mode 100644 index 000000000..45e2580d9 --- /dev/null +++ b/vllm_omni/deploy/mammoth_moda2_ar.yaml @@ -0,0 +1,14 @@ +# MammothModa2 AR-only (image/video understanding and summarization). + +async_chunk: false +pipeline: mammoth_moda2_ar + +stages: + - stage_id: 0 + devices: "0" + max_num_seqs: 16 + max_model_len: 8192 + gpu_memory_utilization: 0.5 + enforce_eager: true + trust_remote_code: true + enable_prefix_caching: false diff --git a/vllm_omni/deploy/omnivoice.yaml b/vllm_omni/deploy/omnivoice.yaml index 6491be6dc..6769dd5e9 100644 --- a/vllm_omni/deploy/omnivoice.yaml +++ b/vllm_omni/deploy/omnivoice.yaml @@ -1,19 +1,13 @@ # OmniVoice TTS deploy: diffusion engine single stage (dit). # Text → iterative unmasking → codebook tokens → DAC decode → 24kHz audio. -# (Migrated from model_executor/stage_configs/omnivoice.yaml for deploy/ layout.) -stage_args: +async_chunk: false +pipeline: omnivoice + +stages: - stage_id: 0 - stage_type: diffusion - is_comprehension: true - runtime: - devices: 0 - engine_args: - model_stage: dit - model_class_name: "OmniVoicePipeline" - enforce_eager: true - trust_remote_code: true - distributed_executor_backend: "mp" - dtype: "float32" - final_output: true - final_output_type: audio + devices: "0" + enforce_eager: true + trust_remote_code: true + distributed_executor_backend: "mp" + dtype: "float32" diff --git a/vllm_omni/diffusion/models/mammoth_moda2/pipeline_mammothmoda2_dit.py b/vllm_omni/diffusion/models/mammoth_moda2/pipeline_mammothmoda2_dit.py index 0c312aa7e..614c8268c 100644 --- a/vllm_omni/diffusion/models/mammoth_moda2/pipeline_mammothmoda2_dit.py +++ b/vllm_omni/diffusion/models/mammoth_moda2/pipeline_mammothmoda2_dit.py @@ -9,6 +9,7 @@ from diffusers.utils.torch_utils import randn_tensor from torch import nn from transformers.models.qwen2.modeling_qwen2 import Qwen2RMSNorm from vllm.config import VllmConfig +from vllm.logger import init_logger from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery @@ -19,6 +20,8 @@ from .mammothmoda2_dit_model import SimpleQFormerImageRefiner, Transformer2DMode from .rope_real import RotaryPosEmbedReal from .schedulers import FlowMatchEulerDiscreteScheduler +logger = init_logger(__name__) + class MammothModa2DiTPipeline(nn.Module, SupportsComponentDiscovery): """ @@ -60,14 +63,14 @@ class MammothModa2DiTPipeline(nn.Module, SupportsComponentDiscovery): self.gen_transformer = Transformer2DModel.from_config(self.config.gen_dit_config) # llm_config is a Mammothmoda2Qwen2_5_VLConfig which has nested text_config - llm_hidden_size = int( - getattr( - getattr(self.config.llm_config, "text_config", None), - "hidden_size", - 0, - ) - or 0 - ) + llm_hidden_size = 0 + text_config = self.config.get_text_config() + if text_config is None: + logger.warning("No text config; failed to infer llm_hidden_size.") + elif not hasattr(text_config, "hidden_size"): + logger.warning("Text config exists, but has no hidden_size attribute; failed to infer llm_hidden_size.") + else: + llm_hidden_size = int(text_config.hidden_size or 0) if llm_hidden_size <= 0: raise ValueError( "Failed to infer llm hidden_size from Mammothmoda2Config.llm_config.text_config.hidden_size" diff --git a/vllm_omni/model_executor/models/mammoth_moda2/pipeline.py b/vllm_omni/model_executor/models/mammoth_moda2/pipeline.py new file mode 100644 index 000000000..be35eb562 --- /dev/null +++ b/vllm_omni/model_executor/models/mammoth_moda2/pipeline.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""MammothModa2 pipeline topology (frozen). + +Stage 0: AR — multimodal understanding + latent generation +Stage 1: DiT — latent → image + +For text/image understanding tasks (text output only), use MAMMOTH_MODA2_AR_PIPELINE. +""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_PROC = "vllm_omni.model_executor.stage_input_processors.mammoth_moda2" + +MAMMOTH_MODA2_PIPELINE = PipelineConfig( + model_type="mammoth_moda2", + model_arch="MammothModa2ForConditionalGeneration", + hf_architectures=("Mammothmoda2Model", "MammothModa2ForConditionalGeneration"), + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="ar", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + final_output=False, + owns_tokenizer=True, + requires_multimodal_data=True, + engine_output_type="latent", + ), + StagePipelineConfig( + stage_id=1, + model_stage="dit", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="image", + owns_tokenizer=False, + requires_multimodal_data=False, + engine_output_type="image", + custom_process_input_func=f"{_PROC}.ar2dit", + ), + ), +) + +# Single-stage AR variant for understanding / summarization tasks +MAMMOTH_MODA2_AR_PIPELINE = PipelineConfig( + model_type="mammoth_moda2_ar", + model_arch="MammothModa2ForConditionalGeneration", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="ar", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + final_output=True, + final_output_type="text", + owns_tokenizer=True, + requires_multimodal_data=True, + engine_output_type="text", + ), + ), +) diff --git a/vllm_omni/model_executor/models/omnivoice/pipeline.py b/vllm_omni/model_executor/models/omnivoice/pipeline.py new file mode 100644 index 000000000..88d479459 --- /dev/null +++ b/vllm_omni/model_executor/models/omnivoice/pipeline.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""OmniVoice pipeline topology (frozen). + +Single-stage diffusion model for text-to-speech. +Stage 0: DiT — text → iterative unmasking → codebook tokens → DAC decode → 24kHz audio +""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +OMNIVOICE_PIPELINE = PipelineConfig( + model_type="omnivoice", + model_arch="OmniVoicePipeline", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="dit", + execution_type=StageExecutionType.DIFFUSION, + input_sources=(), + final_output=True, + final_output_type="audio", + owns_tokenizer=False, + requires_multimodal_data=False, + engine_output_type="audio", + ), + ), +) diff --git a/vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml b/vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml deleted file mode 100644 index f5e449253..000000000 --- a/vllm_omni/model_executor/stage_configs/mammoth_moda2.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# Stage config for running MammothModa2 with full AR→DiT pipeline (text-to-image). -# Stage 0: AR (multimodal understanding + latent generation) -# Stage 1: DiT (latent → image) -# -# For text/image understanding tasks (text output only), use mammoth_moda2_ar.yaml. - -stage_args: - - stage_id: 0 - runtime: - devices: "0" - engine_args: - model_stage: ar - max_num_seqs: 100 - model_arch: MammothModa2ForConditionalGeneration - worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - max_model_len: 8192 - gpu_memory_utilization: 0.5 - enforce_eager: true - trust_remote_code: true - engine_output_type: latent - enable_prefix_caching: false - final_output: false - - - stage_id: 1 - runtime: - devices: "0" - engine_args: - model_stage: dit - max_num_seqs: 1 - model_arch: MammothModa2ForConditionalGeneration - worker_cls: vllm_omni.worker.gpu_generation_worker.GPUGenerationWorker - scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler - gpu_memory_utilization: 0.3 - enforce_eager: true - trust_remote_code: true - engine_output_type: image - enable_prefix_caching: false - # Declares extra_args so has_sampling_extra_args is enabled, routing the t2i knobs - # (text_guidance_scale, cfg_range, num_inference_steps) declared in - # vllm_omni/model_extras/mammothmodal2_preview.py from extra_body into the DiT - # forward. Values here are the defaults applied when a request omits a knob. - default_sampling_params: - extra_args: - text_guidance_scale: 9.0 - cfg_range: [0.0, 1.0] - num_inference_steps: 50 - engine_input_source: [0] - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mammoth_moda2.ar2dit - final_output: true - final_output_type: image diff --git a/vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml b/vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml deleted file mode 100644 index d455c7973..000000000 --- a/vllm_omni/model_executor/stage_configs/mammoth_moda2_ar.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Stage config for running MammothModa2 with single AR stage (understanding / summarization). -# Stage 0: AR (multimodal understanding + text generation) -# -# Use this config for image/video understanding tasks where only text output is needed. -# For text-to-image generation (AR → DiT), use mammoth_moda2.yaml instead. - -stage_args: - - stage_id: 0 - runtime: - devices: "0" - engine_args: - model_stage: ar - max_num_seqs: 16 - model_arch: MammothModa2ForConditionalGeneration - worker_cls: vllm_omni.worker.gpu_ar_worker.GPUARWorker - scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler - max_model_len: 8192 - gpu_memory_utilization: 0.5 - enforce_eager: true - trust_remote_code: true - engine_output_type: text - enable_prefix_caching: false - final_output: true - final_output_type: text From d5e197fb97a0cd0a8071c7465b96de08fdb8a3e3 Mon Sep 17 00:00:00 2001 From: Zhang Jian Date: Wed, 8 Jul 2026 10:29:41 +0800 Subject: [PATCH 041/101] [Diffusion][Feature] Add SP for Omnigen2 (#3206) Signed-off-by: Zhang Jian --- docs/user_guide/diffusion_features.md | 2 +- .../models/omnigen2/omnigen2_transformer.py | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 6b923a793..81a5aee66 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -126,7 +126,7 @@ The following tables show which models support each feature: | **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | **MammothModa2(T2I)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Nextstep_1(T2I)** | ❓ | ❓ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| **OmniGen2** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **OmniGen2** | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Ovis-Image** | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | **Qwen-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ | | **Qwen-Image-2512** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ✅ | ✅ | diff --git a/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py b/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py index 3f03563a1..8c2c9db88 100644 --- a/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py +++ b/vllm_omni/diffusion/models/omnigen2/omnigen2_transformer.py @@ -22,6 +22,10 @@ from vllm.model_executor.layers.quantization import QuantizationConfig from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.distributed.sp_plan import ( + SequenceParallelInput, + SequenceParallelOutput, +) from vllm_omni.platforms import current_omni_platform logger = logging.getLogger(__name__) @@ -808,6 +812,36 @@ class OmniGen2TransformerBlock(nn.Module): return hidden_states +class _OmniGen2SPInputBoundary(nn.Module): + """SP input-sharding boundary marker for ``OmniGen2Transformer2DModel._sp_plan``. + + Resets ``sp_original_seq_len`` on every call: OmniGen2 runs the transformer + once per CFG branch and the unconditional / conditional branches have + different joint sequence lengths (text prefix differs). The SP framework's + auto-pad records the length only on the first call, so without this reset + a stale length from an earlier branch truncates the gather of later ones. + """ + + def forward(self, hidden_states: torch.Tensor, rotary_emb: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + from vllm_omni.diffusion.forward_context import ( + get_forward_context, + is_forward_context_available, + ) + + if is_forward_context_available(): + ctx = get_forward_context() + ctx.sp_original_seq_len = None + ctx.sp_padding_size = 0 + return hidden_states, rotary_emb + + +class _OmniGen2SPOutputBoundary(nn.Module): + """SP output-gathering boundary marker for ``OmniGen2Transformer2DModel._sp_plan``.""" + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + return hidden_states + + class OmniGen2Transformer2DModel(nn.Module): """ OmniGen2 Transformer 2D Model. @@ -836,6 +870,17 @@ class OmniGen2Transformer2DModel(nn.Module): timestep_scale: Scale factor for timestep embeddings """ + # ``attention_mask`` is intentionally omitted from the plan: OmniGen2Attention + # accepts but never reads it. ``auto_pad=True`` requires Ulysses SP — Ring SP + # cannot use attention masks. + _sp_plan = { + "sp_input_boundary": { + 0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True, auto_pad=True), + 1: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True, auto_pad=True), + }, + "sp_output_boundary": SequenceParallelOutput(gather_dim=1, expected_dims=3), + } + def __init__( self, patch_size: int = 2, @@ -983,6 +1028,9 @@ class OmniGen2Transformer2DModel(nn.Module): # Add learnable embeddings to distinguish different images self.image_index_embedding = nn.Parameter(torch.randn(5, hidden_size)) # support max 5 ref images + self.sp_input_boundary = _OmniGen2SPInputBoundary() + self.sp_output_boundary = _OmniGen2SPOutputBoundary() + def img_patch_embed_and_refine( self, hidden_states, @@ -1251,9 +1299,13 @@ class OmniGen2Transformer2DModel(nn.Module): hidden_states = joint_hidden_states + hidden_states, rotary_emb = self.sp_input_boundary(hidden_states, rotary_emb) + for layer in self.layers: hidden_states = layer(hidden_states, attention_mask, rotary_emb, temb) + hidden_states = self.sp_output_boundary(hidden_states) + # 4. Output norm & projection hidden_states = self.norm_out(hidden_states, temb) From 17e27c80e31c7d37e44e9d7de8f066b67b987b13 Mon Sep 17 00:00:00 2001 From: Hyoseop Song Date: Wed, 8 Jul 2026 12:20:30 +0900 Subject: [PATCH 042/101] [Test] Add image-to-video offline example docs and tests (#3573) Signed-off-by: loveysuby Signed-off-by: Hyoseop Song Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com> Co-authored-by: loveysuby Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com> --- .buildkite/test-nightly.yml | 38 ++++ .../image_to_video/README.md | 206 +++++++++++++++++- .../offline_inference/test_image_to_video.py | 81 +++++++ 3 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 tests/examples/offline_inference/test_image_to_video.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 5eede7ae0..85f078533 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -1287,6 +1287,44 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate + - label: ":full_moon: Diffusion X2V · Doc Test" + timeout_in_minutes: 60 + commands: + - pytest -s -v tests/examples/offline_inference/test_image_to_video.py -m "full_model and example and H100" --run-level "full_model" + agents: + queue: "mithril-h100-pool" + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 1 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: hf-token-secret + key: token + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + - label: ":full_moon: Diffusion X2V · Other Function Test" timeout_in_minutes: 90 commands: diff --git a/examples/offline_inference/image_to_video/README.md b/examples/offline_inference/image_to_video/README.md index 17977bb7a..c161022a5 100644 --- a/examples/offline_inference/image_to_video/README.md +++ b/examples/offline_inference/image_to_video/README.md @@ -3,14 +3,140 @@ This shared example generates videos from images with VACE, Wan2.2, LTX-2/LTX-2.3, HunyuanVideo-1.5, Cosmos3, and other compatible pipelines. -## Local CLI Usage +- `image_to_video.py`: command-line script for single video generation with advanced options. -Download the example image: +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Quick Start](#quick-start) +- [Key Arguments](#key-arguments) +- [More CLI Examples](#more-cli-examples) +- [Advanced Features](#advanced-features) +- [FAQ](#faq) + +## Overview + +This folder provides a unified CLI script for image-to-video generation using vLLM-Omni diffusion/video pipelines. The script selects practical defaults for supported model families while still exposing common sampling, memory, and parallelism options. + +### Supported Models + +| Model | Default Resolution | Default Frames | Default Steps | Guidance | VRAM Notes | +| ----- | ------------------ | -------------- | ------------- | -------- | ---------- | +| `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | 480 x 832 | 81 | 50 | 5.0 | Around 60 GiB BF16 for basic single-card usage | +| `Wan-AI/Wan2.2-TI2V-5B-Diffusers` | 480 x 832 | 81 | 50 | 4.0 | Around 20–25 GiB BF16, smallest I2V model | +| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v` | 480 x 832 | 121 | 50 | 6.0 | Around 100 GiB at default settings; the example enables `--enable-cpu-offload` + VAE tiling/slicing to fit an 80 GiB card | +| LTX2 (local path + `--model-class-name LTX2ImageToVideoPipeline`) | 512 x 768 | 121 | 40 | 4.0 | Memory use depends on frame count and tensor parallelism | + +!!! info + Peak VRAM: based on basic single-card usage, batch size = 1, without any acceleration/optimization features. Some model weights cannot fit into one card with 80 GiB VRAM, which may need to use CPU offloading. + +Default model: `Wan-AI/Wan2.2-I2V-A14B-Diffusers`. + +## Prerequisites + +Download the example image used in the snippets below: ```bash -wget https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg +curl -L -o cherry_blossom.jpg https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg ``` +## Quick Start + +### Python API + +Single-prompt generation using TI2V-5B (lightweight): + +```python +import PIL.Image +import torch + +from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +if __name__ == "__main__": + image = PIL.Image.open("cherry_blossom.jpg").convert("RGB") + image = image.resize((576, 320)) + + omni = Omni( + model="Wan-AI/Wan2.2-TI2V-5B-Diffusers", + flow_shift=12.0, + ) + outputs = omni.generate( + { + "prompt": "Cherry blossoms swaying gently in the breeze, petals falling", + "multi_modal_data": {"image": image}, + }, + OmniDiffusionSamplingParams( + height=320, + width=576, + num_frames=17, + num_inference_steps=20, + guidance_scale=4.0, + generator=torch.Generator(device="cuda").manual_seed(42), + ), + ) + from diffusers.utils import export_to_video + frames = outputs[0].request_output.images + export_to_video(frames, "quick_test_i2v.mp4", fps=16) +``` + +### Local CLI Usage + +Quick test using TI2V-5B with a small resolution and few frames: + +```bash +python image_to_video.py \ + --model Wan-AI/Wan2.2-TI2V-5B-Diffusers \ + --image cherry_blossom.jpg \ + --prompt "Cherry blossoms swaying gently in the breeze, petals falling, smooth motion" \ + --height 320 \ + --width 576 \ + --num-frames 17 \ + --guidance-scale 4.0 \ + --num-inference-steps 20 \ + --flow-shift 12.0 \ + --fps 16 \ + --output quick_test_i2v.mp4 +``` + +## Key Arguments + +| Argument | Type | Default | Description | +| -------- | ---- | ------- | ----------- | +| `--model` | str | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | Diffusers I2V model ID or local path | +| `--model-class-name` | str | `None` | Override model class name (e.g., `LTX2ImageToVideoPipeline`) | +| `--image` | str | (required) | Path to input image | +| `--prompt` | str | `""` | Text description of desired motion/animation | +| `--negative-prompt` | str | `""` | Optional list of artifacts to suppress | +| `--seed` | int | `42` | Random seed for deterministic sampling | +| `--guidance-scale` | float | `5.0` | CFG scale | +| `--guidance-scale-high` | float | `None` | Separate CFG for high-noise stage (MoE only) | +| `--height` | int | auto | Video height (auto-calculated from image if not set). Multiples of 16 | +| `--width` | int | auto | Video width (auto-calculated from image if not set). Multiples of 16 | +| `--num-frames` | int | `81` | Number of frames | +| `--num-inference-steps` | int | `50` | Number of denoising steps | +| `--boundary-ratio` | float | `0.875` | Boundary split ratio for two-stage MoE models | +| `--flow-shift` | float | `5.0` | Scheduler flow shift (5.0 for 720p, 12.0 for 480p) | +| `--sample-solver` | str | `unipc` | Wan2.2 sampling solver (`unipc` or `euler` for Lightning/Distill) | +| `--fps` | int | `None` | Frames per second for the saved MP4 | +| `--frame-rate` | float | `None` | Generation frame rate for pipelines that require it (e.g., LTX2) | +| `--output` | str | `i2v_output.mp4` | Path to save the generated video | +| `--vae-use-slicing` | flag | off | Enable VAE slicing for memory optimization | +| `--vae-use-tiling` | flag | off | Enable VAE tiling for memory optimization | +| `--enable-cpu-offload` | flag | off | Enable CPU offloading for diffusion models | +| `--enable-layerwise-offload` | flag | off | Enable layerwise offloading on DiT modules | +| `--cfg-parallel-size` | int | `1` | Set to `2` to enable CFG Parallel | +| `--tensor-parallel-size` | int | `1` | Tensor parallel size (effective for models that support TP, e.g. LTX2) | +| `--ulysses-degree` | int | `1` | Ulysses sequence parallel degree | +| `--ring-degree` | int | `1` | Ring sequence parallel degree | +| `--cache-backend` | str | `None` | Cache backend: `cache_dit` or `tea_cache` | +| `--use-hsdp` | flag | off | Enable Hybrid Sharded Data Parallel | +| `--hsdp-shard-size` | int | `-1` | GPUs per shard group (-1 auto-calculates) | +| `--hsdp-replicate-size` | int | `1` | Number of replica groups for HSDP | + +## More CLI Examples + ### Wan2.2-I2V-A14B-Diffusers (MoE) ```bash @@ -18,7 +144,7 @@ python image_to_video.py \ --model Wan-AI/Wan2.2-I2V-A14B-Diffusers \ --image cherry_blossom.jpg \ --prompt "Cherry blossoms swaying gently in the breeze, petals falling, smooth motion" \ - --negative-prompt "" \ + --negative-prompt "low quality, blurry" \ --height 480 \ --width 832 \ --num-frames 48 \ @@ -28,7 +154,7 @@ python image_to_video.py \ --boundary-ratio 0.875 \ --flow-shift 12.0 \ --fps 16 \ - --output i2v_output.mp4 + --output i2v_wan_moe.mp4 ``` ### Wan2.2-TI2V-5B-Diffusers (Unified) @@ -38,7 +164,7 @@ python image_to_video.py \ --model Wan-AI/Wan2.2-TI2V-5B-Diffusers \ --image cherry_blossom.jpg \ --prompt "Cherry blossoms swaying gently in the breeze, petals falling, smooth motion" \ - --negative-prompt "" \ + --negative-prompt "low quality, blurry" \ --height 480 \ --width 832 \ --num-frames 48 \ @@ -46,7 +172,65 @@ python image_to_video.py \ --num-inference-steps 40 \ --flow-shift 12.0 \ --fps 16 \ - --output i2v_output.mp4 + --output i2v_wan_ti2v.mp4 +``` + +### HunyuanVideo-1.5 I2V (480p) + +```bash +python image_to_video.py \ + --model hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v \ + --image cherry_blossom.jpg \ + --prompt "Cherry blossoms swaying gently in the breeze, petals falling, smooth motion" \ + --height 480 \ + --width 832 \ + --num-frames 121 \ + --guidance-scale 6.0 \ + --flow-shift 5.0 \ + --num-inference-steps 50 \ + --fps 24 \ + --enable-cpu-offload \ + --vae-use-tiling \ + --vae-use-slicing \ + --output hunyuan_i2v.mp4 +``` + +### LTX2 Image-to-Video + +```bash +python image_to_video.py \ + --model /path/to/LTX-2 \ + --model-class-name LTX2ImageToVideoPipeline \ + --image cherry_blossom.jpg \ + --prompt "A cinematic dolly shot of cherry blossoms" \ + --height 512 \ + --width 768 \ + --num-frames 121 \ + --num-inference-steps 40 \ + --guidance-scale 4.0 \ + --frame-rate 24 \ + --fps 24 \ + --output ltx2_i2v.mp4 +``` + +## Advanced Features + +### CFG Parallel + +Set `--cfg-parallel-size 2` to enable CFG Parallel for faster inference on multi-GPU setups. +See more examples in the [cfg_parallel user guide](../../../docs/user_guide/diffusion/parallelism/cfg_parallel.md). + +### Cache Acceleration + +Use `--cache-backend cache_dit` for Cache-DiT acceleration or `--cache-backend tea_cache` for Timestep Embedding Aware Cache: + +```bash +python image_to_video.py \ + --model Wan-AI/Wan2.2-I2V-A14B-Diffusers \ + --image cherry_blossom.jpg \ + --prompt "Cherry blossoms swaying gently in the breeze" \ + --cache-backend cache_dit \ + --output i2v_cached.mp4 ``` ### LTX-2.3 @@ -220,3 +404,11 @@ The VACE T2V command is documented in the shared For Wan2.2 LightX2V-converted local Diffusers directories and related LoRA assets, see the [LoRA guide](../../../docs/user_guide/diffusion/lora.md#wan22-lightx2v-offline-assembly). + +## FAQ + +**OOM errors**: Try using `--vae-use-slicing` and `--vae-use-tiling` to reduce memory usage. For very large models, add `--enable-cpu-offload` or `--enable-layerwise-offload`. + +**Auto-calculated resolution**: If `--height` and `--width` are not provided, the script calculates output dimensions from the input image while maintaining aspect ratio and targeting 480 x 832 area (or 512 x 768 for LTX2). + +**Wan2.2 MoE vs unified**: `I2V-A14B` is a larger Mixture-of-Experts model with separate low/high-noise DiT stages (use `--guidance-scale-high` and `--boundary-ratio`). `TI2V-5B` is a smaller unified T2V+I2V model that does not need these extra arguments. diff --git a/tests/examples/offline_inference/test_image_to_video.py b/tests/examples/offline_inference/test_image_to_video.py new file mode 100644 index 000000000..b53648cb3 --- /dev/null +++ b/tests/examples/offline_inference/test_image_to_video.py @@ -0,0 +1,81 @@ +""" +Offline inference tests: image-to-video. +See examples/offline_inference/image_to_video/README.md +""" + +import shutil +from pathlib import Path + +import pytest +import requests + +from tests.examples.helpers import EXAMPLES, ExampleRunner, ReadmeSnippet +from tests.helpers.assertions import assert_video_valid +from tests.helpers.mark import hardware_marks + +pytestmark = [ + pytest.mark.usefixtures("clean_gpu_memory_between_tests"), + pytest.mark.full_model, + pytest.mark.example, + *hardware_marks(res={"cuda": "H100"}), +] + +I2V_SCRIPT = EXAMPLES / "offline_inference" / "image_to_video" / "image_to_video.py" +README_PATH = I2V_SCRIPT.with_name("README.md") +EXAMPLE_OUTPUT_SUBFOLDER = "example_offline_i2v" + +_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg" +_IMAGE_NAME = "cherry_blossom.jpg" + +_SKIP_SECTIONS = { + "Prerequisites", + "Advanced Features", + "FAQ", + # VACE conditional tasks are covered by test_vace_video_generation.py, which + # runs the same README snippets with synthetic assets and smoke settings. The + # prep block here downloads inputs via wget (not portable, not collected), so + # exercising this section from the shared runner would only duplicate and fail. + "Wan2.1 VACE Conditional Tasks", +} + + +def _ensure_test_image(run_dir: Path) -> None: + """Download or copy the example image into run_dir so CLI snippets can find it.""" + dest = run_dir / _IMAGE_NAME + if dest.exists(): + return + src = I2V_SCRIPT.parent / _IMAGE_NAME + if src.exists(): + shutil.copy2(src, dest) + return + response = requests.get(_IMAGE_URL, timeout=60) + response.raise_for_status() + dest.write_bytes(response.content) + + +def _skip_readme_snippet(language: str, code: str, h2_title: str) -> tuple[bool, str]: + if h2_title in _SKIP_SECTIONS: + return True, f"README section '{h2_title}' is intentionally excluded for examples tests" + if language == "python": + return True, "Python API snippets produce video files that ExampleRunner does not auto-collect" + if "/path/to/" in code: + return True, "Snippet references a placeholder local model path" + return False, "" + + +README_SNIPPETS = ReadmeSnippet.extract_readme_snippets(README_PATH, skipif=_skip_readme_snippet) + + +@pytest.mark.parametrize("snippet", README_SNIPPETS, ids=lambda snippet: snippet.test_id) +def test_image_to_video(snippet: ReadmeSnippet, example_runner: ExampleRunner): + should_skip, reason = snippet.skip + if should_skip: + pytest.skip(reason) + + run_dir = example_runner.output_root / EXAMPLE_OUTPUT_SUBFOLDER / snippet.test_id + run_dir.mkdir(parents=True, exist_ok=True) + _ensure_test_image(run_dir) + + result = example_runner.run(snippet, output_subfolder=Path(EXAMPLE_OUTPUT_SUBFOLDER)) + for asset in result.assets: + assert_video_valid(asset) From 4908b85731731da46c4560cbe2b567c94fb032a7 Mon Sep 17 00:00:00 2001 From: Inesh Reddy Chappidi Date: Wed, 8 Jul 2026 01:23:13 -0400 Subject: [PATCH 043/101] [Bugfix] MOSS-TTS codec: resolve decode method across remote-code and vendored tokenizers (#4760) Signed-off-by: Inesh Reddy --- .../models/moss_tts/moss_codec_cudagraph.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py b/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py index ed03c018e..71585f1c0 100644 --- a/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py +++ b/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py @@ -1,6 +1,6 @@ """CUDA Graph acceleration for the MOSS Audio Tokenizer codec decoder. -Captures MossAudioTokenizerModel._decode for a set of fixed frame-count +Captures the codec decode method (_decode_frame, or _decode on the vendored codec) for a set of fixed frame-count bucket sizes, then replays the captured graph at inference time to eliminate kernel-launch overhead. Inputs that exceed all captured sizes fall back to eager execution transparently. @@ -24,7 +24,7 @@ logger = init_logger(__name__) class MossTTSCUDAGraphCodecWrapper: - """CUDA Graph wrapper for MossAudioTokenizerModel._decode. + """CUDA Graph wrapper for the codec decode method (_decode_frame, or _decode on the vendored codec). Graphs are keyed by padded_T (int). On each call the actual T is bucket-matched to the smallest pre-captured size >= T. The static code @@ -51,6 +51,15 @@ class MossTTSCUDAGraphCodecWrapper: enabled: bool = True, ) -> None: self.model = model + # Upstream remote-code tokenizer exposes _decode_frame; the vendored + # MossAudioTokenizerModel still exposes _decode. Resolve once here so + # both codec implementations work. + decode_fn = getattr(model, "_decode_frame", None) or getattr(model, "_decode", None) + if decode_fn is None: + raise AttributeError( + f"{type(model).__module__}.{type(model).__name__} exposes neither _decode_frame nor _decode" + ) + self._decode_fn = decode_fn self.capture_sizes: list[int] = sorted(capture_sizes) self.num_quantizers = num_quantizers self.enabled = enabled @@ -99,7 +108,7 @@ class MossTTSCUDAGraphCodecWrapper: dummy_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device) dummy_lengths = torch.tensor([size], dtype=torch.long, device=device) with torch.no_grad(): - _ = self.model._decode(dummy_codes, dummy_lengths) + _ = self._decode_fn(dummy_codes, dummy_lengths) torch.accelerator.synchronize(device) @@ -132,13 +141,13 @@ class MossTTSCUDAGraphCodecWrapper: # Extra eager warmup inside capture to ensure all kernels are compiled. with torch.no_grad(): - _ = self.model._decode(static_codes, static_lengths) + _ = self._decode_fn(static_codes, static_lengths) torch.accelerator.synchronize(device) graph = CUDAGraph() with torch.no_grad(): with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()): - static_out = self.model._decode(static_codes, static_lengths) + static_out = self._decode_fn(static_codes, static_lengths) self.graphs[size] = graph self.static_codes[size] = static_codes From b96bc5ac873d8859bdd1affc16b1bf498a6f9064 Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Wed, 8 Jul 2026 14:34:36 +0800 Subject: [PATCH 044/101] Support HuggingFace kernels package for native diffusion attention backends (#4926) Signed-off-by: samithuang <285365963@qq.com> Signed-off-by: Samit <285365963@qq.com> --- .../diffusion/attention_backends.md | 32 ++ tests/diffusion/attention/test_kernels_hub.py | 104 +++++ .../attention/backends/flash_attn_hub.py | 406 ++++++++++++++++++ .../diffusion/attention/backends/registry.py | 2 + vllm_omni/platforms/cuda/platform.py | 25 ++ vllm_omni/platforms/musa/platform.py | 8 + vllm_omni/platforms/npu/platform.py | 8 + vllm_omni/platforms/rocm/platform.py | 8 + vllm_omni/platforms/xpu/platform.py | 8 + 9 files changed, 601 insertions(+) create mode 100644 tests/diffusion/attention/test_kernels_hub.py create mode 100644 vllm_omni/diffusion/attention/backends/flash_attn_hub.py diff --git a/docs/user_guide/diffusion/attention_backends.md b/docs/user_guide/diffusion/attention_backends.md index abc63ebed..12446f3fe 100644 --- a/docs/user_guide/diffusion/attention_backends.md +++ b/docs/user_guide/diffusion/attention_backends.md @@ -20,6 +20,8 @@ The full set of backends and their platform defaults is in the **Backend Options | `TORCH_SDPA` | PyTorch `scaled_dot_product_attention` with the default backend dispatcher. Most conservative; always available. | | `SAGE_ATTN` | SageAttention 2.2 — INT8-quantized attention with FP16 accumulation. Lossy but typically visually indistinguishable on diffusion outputs. Requires `sageattention`. | | `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. | +| `FLASH_ATTN_HUB` | FlashAttention 2 from HuggingFace `kernels` library. Useful for train/rollout alignment. | +| `FLASH_ATTN_3_HUB` | FlashAttention 3 from HuggingFace `kernels` library. CUDA Hopper (sm_90+) only; falls back to `FLASH_ATTN_HUB` on older GPUs. | ## Configuration @@ -210,6 +212,36 @@ Notes: - `SAGE_ATTN_3` is only selected on CUDA when `sageattn3` is importable and the GPU is Blackwell-class. - SageAttention3's Blackwell kernel assumes `Hq == Hkv`. In vLLM-Omni, GQA/MQA diffusion requests fall back to PyTorch SDPA for correctness. +## HuggingFace Kernels Hub Backends + +To achieve perfect numerical consistency between **training** (typically using HuggingFace Diffusers with Hub kernels) and **serving/rollout** (using vLLM-Omni), you can use the Hub-based attention backends. This eliminates numerical drift / sampling divergence caused by executing different local kernel versions during rollout. + +The following backend options are supported: +- `FLASH_ATTN_HUB` (HuggingFace `kernels-community/flash-attn2`) +- `FLASH_ATTN_3_HUB` (HuggingFace `kernels-community/flash-attn3`, Hopper sm_90+ only) + +### Installation + +To use these backends, you must install the `kernels` library: + +```bash +pip install kernels==0.14.1 +``` + +If the `kernels` library is not available in the environment, vLLM-Omni will log a warning and fall back gracefully to the corresponding local backend implementations (`FLASH_ATTN`). On CUDA GPUs below Hopper (compute capability < 9.0), `FLASH_ATTN_3_HUB` falls back to `FLASH_ATTN_HUB`. + +### Usage + +Select a Hub backend using the global CLI flag or environment variables: + +```bash +# Environment variable +export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN_HUB + +# CLI flag +vllm-omni serve --diffusion-attention-backend FLASH_ATTN_HUB +``` + ## Usage Examples ### Default (auto-route) diff --git a/tests/diffusion/attention/test_kernels_hub.py b/tests/diffusion/attention/test_kernels_hub.py new file mode 100644 index 000000000..0f22a1244 --- /dev/null +++ b/tests/diffusion/attention/test_kernels_hub.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import sys +import types + +import pytest +import torch + +from tests.helpers.mark import hardware_test +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum +from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl +from vllm_omni.platforms import current_omni_platform + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_kernels_hub_platform_fallback(monkeypatch: pytest.MonkeyPatch): + """Test that when kernels package is not available, the platform fallback logic + + routes to local FLASH_ATTN. + """ + from vllm_omni.platforms.cuda.platform import CudaOmniPlatform + + # Temporarily hide/remove kernels module if present + monkeypatch.setitem(sys.modules, "kernels", None) + + # Use monkeypatch to mock CudaOmniPlatform capability and package checker to allow FLASH_ATTN + from vllm.platforms.interface import DeviceCapability + + from vllm_omni.diffusion.envs import PACKAGES_CHECKER + + monkeypatch.setattr( + CudaOmniPlatform, + "get_device_capability", + classmethod(lambda cls, device_id=0: DeviceCapability(8, 0)), + ) + monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": True}) + + # Test FLASH_ATTN_HUB falls back to FLASH_ATTN when kernels is unavailable + backend_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_HUB", head_size=64) + assert backend_path == DiffusionAttentionBackendEnum.FLASH_ATTN.get_path() + + # Test FLASH_ATTN_3_HUB falls back to FLASH_ATTN when kernels is unavailable + backend_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64) + assert backend_path == DiffusionAttentionBackendEnum.FLASH_ATTN.get_path() + + # Test FLASH_ATTN_3_HUB falls back to FLASH_ATTN_HUB on pre-Hopper GPUs + kernels_module = types.ModuleType("kernels") + monkeypatch.setitem(sys.modules, "kernels", kernels_module) + backend_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64) + assert backend_path == DiffusionAttentionBackendEnum.FLASH_ATTN_HUB.get_path() + + +@pytest.mark.core_model +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_kernels_hub_execution(): + """Verify basic forward of flash_attn_hub and flash_attn_3_hub, comparing with SDPA reference.""" + device = torch.device(current_omni_platform.device_type) + dtype = torch.bfloat16 + + num_heads = 8 + head_dim = 64 + seq_len = 32 + batch_size = 1 + + torch.manual_seed(42) + q = torch.randn(batch_size, seq_len, num_heads, head_dim, device=device, dtype=dtype) + k = q.clone() + v = q.clone() + + # 1. Test PyTorch SDPA reference + sdpa_impl = SDPAImpl(num_heads=num_heads, head_size=head_dim, softmax_scale=1.0 / (head_dim**0.5), causal=False) + attn_metadata_sdpa = AttentionMetadata(attn_mask=None) + output_ref = sdpa_impl.forward(q.clone(), k.clone(), v.clone(), attn_metadata_sdpa) + + # 2. Test FlashAttentionHubBackend (FlashAttention 2) + from vllm_omni.diffusion.attention.backends.flash_attn_hub import FlashAttentionHubImpl + + fa_hub_impl = FlashAttentionHubImpl( + num_heads=num_heads, head_size=head_dim, softmax_scale=1.0 / (head_dim**0.5), causal=False + ) + output_fa_hub = fa_hub_impl.forward(q.clone(), k.clone(), v.clone(), attn_metadata_sdpa) + assert output_fa_hub.shape == q.shape + assert not torch.isnan(output_fa_hub).any() + max_diff = torch.max(torch.abs(output_ref - output_fa_hub)).item() + assert max_diff < 1e-2, f"FlashAttentionHub output differs too much from SDPA reference: {max_diff}" + + # 3. Test FlashAttention3HubBackend (FlashAttention 3, Hopper+ only) + major, _minor = torch.cuda.get_device_capability() + if major < 9: + pytest.skip("FLASH_ATTN_3_HUB execution requires Hopper-class GPU (compute capability >= 9.0)") + + from vllm_omni.diffusion.attention.backends.flash_attn_hub import FlashAttention3HubImpl + + fa3_hub_impl = FlashAttention3HubImpl( + num_heads=num_heads, head_size=head_dim, softmax_scale=1.0 / (head_dim**0.5), causal=False + ) + output_fa3_hub = fa3_hub_impl.forward(q.clone(), k.clone(), v.clone(), attn_metadata_sdpa) + assert output_fa3_hub.shape == q.shape + assert not torch.isnan(output_fa3_hub).any() + max_diff = torch.max(torch.abs(output_ref - output_fa3_hub)).item() + assert max_diff < 1e-2, f"FlashAttention3Hub output differs too much from SDPA reference: {max_diff}" diff --git a/vllm_omni/diffusion/attention/backends/flash_attn_hub.py b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py new file mode 100644 index 000000000..bfb59b46f --- /dev/null +++ b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py @@ -0,0 +1,406 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from functools import partial + +import torch +from vllm.logger import init_logger + +from vllm_omni.diffusion.attention.backends.abstract import ( + AttentionBackend, + AttentionImpl, + AttentionMetadata, +) +from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import ( + piecewise_attn, +) + +logger = init_logger(__name__) + + +def _run_varlen_dense( + varlen_func, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + *, + causal: bool, + softmax_scale: float, +) -> torch.Tensor: + batch_size, q_len = query.size()[:2] + k_len = key.size(1) + cu_seqlens_q = torch.arange(0, (batch_size + 1) * q_len, step=q_len, dtype=torch.int32, device=query.device) + cu_seqlens_k = torch.arange(0, (batch_size + 1) * k_len, step=k_len, dtype=torch.int32, device=query.device) + + out = varlen_func( + q=query.flatten(0, 1), + k=key.flatten(0, 1), + v=value.flatten(0, 1), + cu_seqlens_q=cu_seqlens_q, + cu_seqlens_k=cu_seqlens_k, + max_seqlen_q=q_len, + max_seqlen_k=k_len, + causal=causal, + softmax_scale=softmax_scale, + ) + if isinstance(out, tuple): + out = out[0] + return out.reshape(batch_size, q_len, *out.shape[1:]) + + +class FlashAttentionHubBackend(AttentionBackend): + accept_output_buffer: bool = True + + @classmethod + def supports_attention_mask(cls) -> bool: + return True + + @staticmethod + def get_supported_head_sizes() -> list[int]: + return [64, 96, 128, 192, 256] + + @staticmethod + def get_name() -> str: + return "FLASH_ATTN_HUB" + + @staticmethod + def get_impl_cls() -> type["FlashAttentionHubImpl"]: + return FlashAttentionHubImpl + + +class FlashAttentionHubImpl(AttentionImpl): + def __init__( + self, + num_heads: int, + head_size: int, + softmax_scale: float, + causal: bool = False, + num_kv_heads: int | None = None, + prefix: str = "", + qkv_layout: str | None = None, + backend_kwargs: dict | None = None, + **extra_impl_args, + ) -> None: + self.num_heads = num_heads + self.causal = causal + self.softmax_scale = softmax_scale + self.qkv_layout = qkv_layout + if backend_kwargs: + logger.warning("FlashAttentionHubImpl ignoring backend_kwargs: %s", list(backend_kwargs.keys())) + + # Lazily get the kernel from the Hub to avoid network/disk overhead on import + from kernels import get_kernel + + logger.info("Loading flash-attn2 kernel from HuggingFace Hub...") + try: + hub_module = get_kernel("kernels-community/flash-attn2", version=1) + except Exception as e: + try: + logger.info("Failed to load version 1, attempting version 2: %s", e) + hub_module = get_kernel("kernels-community/flash-attn2", version=2) + except Exception as e2: + logger.info("Failed to load version 2, attempting default: %s", e2) + hub_module = get_kernel("kernels-community/flash-attn2") + + self.flash_attn_func = getattr(hub_module, "flash_attn_func", None) + self.flash_attn_varlen_func = getattr(hub_module, "flash_attn_varlen_func", None) + + if self.flash_attn_func is None and self.flash_attn_varlen_func is None: + raise RuntimeError("Failed to load flash-attn2 kernel from HuggingFace Hub: no functions found") + + @staticmethod + def _unwrap_flash_output(out: torch.Tensor | tuple[torch.Tensor, ...]) -> torch.Tensor: + # FA3 may return (out, lse), FA2 returns out + return out[0] if isinstance(out, tuple) else out + + def _flash_wrapper(self, q, k, v, *, attn_func, **kwargs): + if attn_func is not None: + return self._unwrap_flash_output(attn_func(q, k, v, **kwargs)) + return _run_varlen_dense( + self.flash_attn_varlen_func, + q, + k, + v, + causal=kwargs.get("causal", self.causal), + softmax_scale=kwargs.get("softmax_scale", self.softmax_scale), + ) + + def _forward_varlen_masked( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attention_mask: torch.Tensor, + ) -> torch.Tensor: + from vllm_omni.diffusion.attention.backends.utils.fa import ( + _pad_input, + _unpad_input, + _upad_input, + ) + + assert attention_mask.ndim == 2, "attention_mask must be 2D, (batch_size, seq_len)" + query_length = query.size(1) + q, k, v, indices_q, (cu_seq_lens_q, cu_seq_lens_k), (max_length_q, max_length_k) = _upad_input( + query, key, value, attention_mask, query_length, _unpad_input + ) + + out_unpad = self.flash_attn_varlen_func( + q, + k, + v, + cu_seqlens_q=cu_seq_lens_q, + cu_seqlens_k=cu_seq_lens_k, + max_seqlen_q=max_length_q, + max_seqlen_k=max_length_k, + **{ + "causal": self.causal, + "softmax_scale": self.softmax_scale, + }, + ) + out_unpad = self._unwrap_flash_output(out_unpad) + return _pad_input(out_unpad, indices_q, query.size(0), query_length) + + def _forward_varlen_dense( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + ) -> torch.Tensor: + return _run_varlen_dense( + self.flash_attn_varlen_func, + query, + key, + value, + causal=self.causal, + softmax_scale=self.softmax_scale, + ) + + def forward_cuda( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attn_metadata: AttentionMetadata = None, + ) -> torch.Tensor: + attention_mask = attn_metadata.attn_mask if attn_metadata is not None else None + full_attn_spans = attn_metadata.full_attn_spans if attn_metadata is not None else None + + # Try piecewise attention + if full_attn_spans is not None: + logger.debug("Using piecewise Flash Attention for mixed causal/full mask") + attn_func = partial( + self._flash_wrapper, + attn_func=self.flash_attn_func, + ) + + return piecewise_attn( + query, + key, + value, + full_attn_spans, + self.softmax_scale, + attn_func, + ) + + if attention_mask is not None and torch.any(~attention_mask): + return self._forward_varlen_masked( + query, + key, + value, + attention_mask, + ) + + if self.flash_attn_func is not None: + out = self.flash_attn_func( + query, + key, + value, + causal=self.causal, + softmax_scale=self.softmax_scale, + ) + return self._unwrap_flash_output(out) + + return self._forward_varlen_dense( + query, + key, + value, + ) + + +class FlashAttention3HubBackend(AttentionBackend): + accept_output_buffer: bool = True + + @classmethod + def supports_attention_mask(cls) -> bool: + return True + + @staticmethod + def get_supported_head_sizes() -> list[int]: + return [64, 96, 128, 192, 256] + + @staticmethod + def get_name() -> str: + return "FLASH_ATTN_3_HUB" + + @staticmethod + def get_impl_cls() -> type["FlashAttention3HubImpl"]: + return FlashAttention3HubImpl + + +class FlashAttention3HubImpl(AttentionImpl): + def __init__( + self, + num_heads: int, + head_size: int, + softmax_scale: float, + causal: bool = False, + num_kv_heads: int | None = None, + prefix: str = "", + qkv_layout: str | None = None, + backend_kwargs: dict | None = None, + **extra_impl_args, + ) -> None: + self.num_heads = num_heads + self.causal = causal + self.softmax_scale = softmax_scale + self.qkv_layout = qkv_layout + if backend_kwargs: + logger.warning("FlashAttention3HubImpl ignoring backend_kwargs: %s", list(backend_kwargs.keys())) + + # Lazily get the kernel from the Hub to avoid network/disk overhead on import + from kernels import get_kernel + + logger.info("Loading flash-attn3 kernel from HuggingFace Hub...") + try: + hub_module = get_kernel("kernels-community/flash-attn3", version=1) + except Exception as e: + try: + logger.info("Failed to load version 1, attempting version 2: %s", e) + hub_module = get_kernel("kernels-community/flash-attn3", version=2) + except Exception as e2: + logger.info("Failed to load version 2, attempting default: %s", e2) + hub_module = get_kernel("kernels-community/flash-attn3") + + self.flash_attn_func = getattr(hub_module, "flash_attn_func", None) + self.flash_attn_varlen_func = getattr(hub_module, "flash_attn_varlen_func", None) + + if self.flash_attn_func is None and self.flash_attn_varlen_func is None: + raise RuntimeError("Failed to load flash-attn3 kernel from HuggingFace Hub: no functions found") + + @staticmethod + def _unwrap_flash_output(out: torch.Tensor | tuple[torch.Tensor, ...]) -> torch.Tensor: + # FA3 returns (out, lse) + return out[0] if isinstance(out, tuple) else out + + def _flash_wrapper(self, q, k, v, *, attn_func, **kwargs): + if attn_func is not None: + return self._unwrap_flash_output(attn_func(q, k, v, **kwargs)) + return _run_varlen_dense( + self.flash_attn_varlen_func, + q, + k, + v, + causal=kwargs.get("causal", self.causal), + softmax_scale=kwargs.get("softmax_scale", self.softmax_scale), + ) + + def _forward_varlen_masked( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attention_mask: torch.Tensor, + ) -> torch.Tensor: + from vllm_omni.diffusion.attention.backends.utils.fa import ( + _pad_input, + _unpad_input, + _upad_input, + ) + + assert attention_mask.ndim == 2, "attention_mask must be 2D, (batch_size, seq_len)" + query_length = query.size(1) + q, k, v, indices_q, (cu_seq_lens_q, cu_seq_lens_k), (max_length_q, max_length_k) = _upad_input( + query, key, value, attention_mask, query_length, _unpad_input + ) + + out_unpad = self.flash_attn_varlen_func( + q, + k, + v, + cu_seqlens_q=cu_seq_lens_q, + cu_seqlens_k=cu_seq_lens_k, + max_seqlen_q=max_length_q, + max_seqlen_k=max_length_k, + **{ + "causal": self.causal, + "softmax_scale": self.softmax_scale, + }, + ) + out_unpad = self._unwrap_flash_output(out_unpad) + return _pad_input(out_unpad, indices_q, query.size(0), query_length) + + def _forward_varlen_dense( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + ) -> torch.Tensor: + return _run_varlen_dense( + self.flash_attn_varlen_func, + query, + key, + value, + causal=self.causal, + softmax_scale=self.softmax_scale, + ) + + def forward_cuda( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attn_metadata: AttentionMetadata = None, + ) -> torch.Tensor: + attention_mask = attn_metadata.attn_mask if attn_metadata is not None else None + full_attn_spans = attn_metadata.full_attn_spans if attn_metadata is not None else None + + # Try piecewise attention + if full_attn_spans is not None: + logger.debug("Using piecewise Flash Attention for mixed causal/full mask") + attn_func = partial( + self._flash_wrapper, + attn_func=self.flash_attn_func, + ) + + return piecewise_attn( + query, + key, + value, + full_attn_spans, + self.softmax_scale, + attn_func, + ) + + if attention_mask is not None and torch.any(~attention_mask): + return self._forward_varlen_masked( + query, + key, + value, + attention_mask, + ) + + if self.flash_attn_func is not None: + out = self.flash_attn_func( + query, + key, + value, + causal=self.causal, + softmax_scale=self.softmax_scale, + ) + return self._unwrap_flash_output(out) + + return self._forward_varlen_dense( + query, + key, + value, + ) diff --git a/vllm_omni/diffusion/attention/backends/registry.py b/vllm_omni/diffusion/attention/backends/registry.py index 8c22cc2cd..a822d78fb 100644 --- a/vllm_omni/diffusion/attention/backends/registry.py +++ b/vllm_omni/diffusion/attention/backends/registry.py @@ -62,6 +62,8 @@ class DiffusionAttentionBackendEnum(Enum, metaclass=_DiffusionBackendEnumMeta): SAGE_ATTN_3 = "vllm_omni.diffusion.attention.backends.sage_attn3.SageAttention3Backend" CUDNN_ATTN = "vllm_omni.diffusion.attention.backends.cudnn_attn.CuDNNAttentionBackend" FLASHINFER_ATTN = "vllm_omni.diffusion.attention.backends.flashinfer_attn.FlashInferAttentionBackend" + FLASH_ATTN_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttentionHubBackend" + FLASH_ATTN_3_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttention3HubBackend" def get_path(self, include_classname: bool = True) -> str: """Get the class path for this backend (respects overrides). diff --git a/vllm_omni/platforms/cuda/platform.py b/vllm_omni/platforms/cuda/platform.py index 2127509eb..81eaa3066 100644 --- a/vllm_omni/platforms/cuda/platform.py +++ b/vllm_omni/platforms/cuda/platform.py @@ -121,6 +121,31 @@ class CudaOmniPlatform(OmniPlatform, CudaPlatformBase): if selected_backend is not None: backend_upper = selected_backend.upper() + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + try: + importlib.import_module("kernels") + logger.info("Using HuggingFace kernels-backed attention backend '%s'", backend_upper) + except ImportError: + if backend_upper == "FLASH_ATTN_HUB": + logger.warning( + "HuggingFace `kernels` library is not available. Falling back to local FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + elif backend_upper == "FLASH_ATTN_3_HUB": + logger.warning( + "HuggingFace `kernels` library is not available. Falling back to local FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + + if backend_upper == "FLASH_ATTN_3_HUB": + fa3_hub_supported = compute_capability is not None and compute_capability.major >= 9 + if not fa3_hub_supported: + logger.warning( + "FLASH_ATTN_3_HUB requires a Hopper-class GPU with compute capability >= 9.0. " + "Falling back to FLASH_ATTN_HUB." + ) + backend_upper = "FLASH_ATTN_HUB" + if backend_upper == "FLASH_ATTN" and not flash_attn_supported: if not compute_supported: logger.warning( diff --git a/vllm_omni/platforms/musa/platform.py b/vllm_omni/platforms/musa/platform.py index 96e27edf3..92a194d59 100644 --- a/vllm_omni/platforms/musa/platform.py +++ b/vllm_omni/platforms/musa/platform.py @@ -78,6 +78,14 @@ class MUSAOmniPlatform(OmniPlatform, MUSAPlatformBase): if selected_backend is not None: backend_upper = selected_backend.upper() + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + logger.warning( + "HuggingFace kernels-backed FlashAttention is " + "not supported on MUSA. Falling back to local " + "FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + if backend_upper == "FLASH_ATTN" and not flash_attn_supported: if not compute_supported: logger.warning( diff --git a/vllm_omni/platforms/npu/platform.py b/vllm_omni/platforms/npu/platform.py index f03fdb1bf..30aafdb32 100644 --- a/vllm_omni/platforms/npu/platform.py +++ b/vllm_omni/platforms/npu/platform.py @@ -107,6 +107,14 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform): if selected_backend is not None: backend_upper = selected_backend.upper() + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + logger.warning( + "HuggingFace kernels-backed FlashAttention is " + "not supported on NPU. Falling back to local " + "FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + backend = DiffusionAttentionBackendEnum[backend_upper] logger.debug("Using diffusion attention backend '%s'", backend_upper) return backend.get_path() diff --git a/vllm_omni/platforms/rocm/platform.py b/vllm_omni/platforms/rocm/platform.py index b932d3794..f905fd73c 100644 --- a/vllm_omni/platforms/rocm/platform.py +++ b/vllm_omni/platforms/rocm/platform.py @@ -88,6 +88,14 @@ class RocmOmniPlatform(OmniPlatform, RocmPlatform): if selected_backend is not None: backend_upper = selected_backend.upper() + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + logger.warning( + "HuggingFace kernels-backed FlashAttention is " + "not supported on ROCm. Falling back to local " + "FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + if backend_upper == "FLASH_ATTN" and not aiter_supported: logger.warning( "Flash Attention requires `aiter` library which is only supported " diff --git a/vllm_omni/platforms/xpu/platform.py b/vllm_omni/platforms/xpu/platform.py index 8be01939f..0bc782e03 100644 --- a/vllm_omni/platforms/xpu/platform.py +++ b/vllm_omni/platforms/xpu/platform.py @@ -42,6 +42,14 @@ class XPUOmniPlatform(OmniPlatform, XPUPlatform): if selected_backend is not None: backend_upper = selected_backend.upper() + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + logger.warning( + "HuggingFace kernels-backed FlashAttention is " + "not supported on XPU. Falling back to local " + "FLASH_ATTN." + ) + backend_upper = "FLASH_ATTN" + backend = DiffusionAttentionBackendEnum[backend_upper] logger.debug("Using diffusion attention backend '%s'", backend_upper) return backend.get_path() From 7d4decc1d826076337438987ba11801d5e3b1699 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Wed, 8 Jul 2026 17:45:02 +0800 Subject: [PATCH 045/101] docs(recipe): fix MiniCPM-o 4.5 curl TTS example and clarify chat_template_kwargs usage (#4950) Signed-off-by: amy-why-3459 --- recipes/OpenBMB/MiniCPM-o-4_5.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/recipes/OpenBMB/MiniCPM-o-4_5.md b/recipes/OpenBMB/MiniCPM-o-4_5.md index efc1699b3..8b3d2909d 100644 --- a/recipes/OpenBMB/MiniCPM-o-4_5.md +++ b/recipes/OpenBMB/MiniCPM-o-4_5.md @@ -102,8 +102,9 @@ curl http://localhost:8099/v1/chat/completions \ ``` **Text + speech in one response** (the headline 4.5 feature). The TTS -path is gated by a Jinja flag on the chat template, passed through -`extra_body.chat_template_kwargs.use_tts_template=true`: +path is gated by a Jinja flag on the chat template. Pass +`use_tts_template=true` via the **top-level** `chat_template_kwargs` +field (curl does not flatten nested `extra_body`): ```bash curl http://localhost:8099/v1/chat/completions \ @@ -112,12 +113,18 @@ curl http://localhost:8099/v1/chat/completions \ "model": "openbmb/MiniCPM-o-4_5", "messages": [{"role": "user", "content": "Say hello, then introduce vLLM in one sentence."}], "modalities": ["text", "audio"], - "extra_body": {"chat_template_kwargs": {"use_tts_template": true}} + "chat_template_kwargs": {"use_tts_template": true} }' ``` -Response carries text in `choices[0].message.content` and base64 WAV -in `choices[0].message.audio.data` (24 kHz mono, see Notes). +When using the OpenAI Python SDK, the same flag can also be sent as +`extra_body={"chat_template_kwargs": {"use_tts_template": True}}` +because the client merges `extra_body` into the request root. + +Response carries text in one choice's `message.content` and base64 WAV +in another choice's `message.audio.data` (24 kHz mono, see Notes). With +`modalities: ["text", "audio"]` you typically get two `choices` entries +(one text, one audio). **Gradio demo (text + image + audio + video UI)**: @@ -209,9 +216,13 @@ vllm serve openbmb/MiniCPM-o-4_5 --omni \ missing dep raises `ImportError` at first request with the same install hint instead of silently emitting empty audio. -- **TTS trigger**: speech output is only emitted when the client passes - `extra_body.chat_template_kwargs.use_tts_template=true`. Without it, - the response is text-only (which is also faster). +- **TTS trigger**: speech output requires + `chat_template_kwargs.use_tts_template=true` so the chat template + appends `<|tts_bos|>` before generation. Without it, Stage-1 talker + receives no TTS token span and returns silent audio (not text-only). + For **curl**, put `chat_template_kwargs` at the request root; nested + `extra_body.chat_template_kwargs` is ignored. The OpenAI Python SDK + may use `extra_body` because it flattens those fields into the root. - **Output audio**: 24 kHz mono WAV inside the OpenAI-style `message.audio.data` (base64). The Gradio demo's WAV player decodes From b7d08fe5f6b1306eee44172c6589e3225de6897e Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Wed, 8 Jul 2026 09:00:47 -0600 Subject: [PATCH 046/101] [Core / Bugfix] Add Mechanism for Endpoint Rejection (#4762) Signed-off-by: Alex Brooks --- tests/config/test_endpoint_policy.py | 87 +++++ tests/e2e/online_serving/test_qwen3_omni.py | 24 ++ tests/entrypoints/test_omni_entrypoints.py | 1 + .../test_resolve_dreamzero_config.py | 4 +- tests/helpers/runtime.py | 23 ++ tests/test_config_factory.py | 47 +++ vllm_omni/config/config_factory.py | 356 ++++++++++-------- vllm_omni/config/endpoint_policy.py | 91 +++++ vllm_omni/config/stage_config.py | 2 + vllm_omni/engine/async_omni_engine.py | 13 + vllm_omni/entrypoints/async_omni.py | 1 + vllm_omni/entrypoints/openai/api_server.py | 9 +- vllm_omni/entrypoints/utils.py | 1 + .../models/qwen3_omni/pipeline.py | 7 + 14 files changed, 508 insertions(+), 158 deletions(-) create mode 100644 tests/config/test_endpoint_policy.py create mode 100644 vllm_omni/config/endpoint_policy.py diff --git a/tests/config/test_endpoint_policy.py b/tests/config/test_endpoint_policy.py new file mode 100644 index 000000000..aeb1ffa51 --- /dev/null +++ b/tests/config/test_endpoint_policy.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for endpoint restrictions logic.""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from vllm_omni.config.endpoint_policy import ( + EndpointRestriction, + OmniServingCapability, + shutdown_unsupported_routes, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +SERVER_ROUTE = "/v1/completions" +REJECTION_REASON = "This route is banned!" + + +def _make_app_with_server_route(): + """Create a FastAPI app with a route to reject.""" + app = FastAPI() + + @app.post(SERVER_ROUTE) + async def existing_handler(): + """Returns a response; we want to disable this.""" + return {"ok": True} + + return app + + +def test_restricted_completions_returns_400(): + """Ensure that simple route rejection works.""" + app = _make_app_with_server_route() + restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),) + shutdown_unsupported_routes(app, restrictions) + + client = TestClient(app) + resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"}) + assert resp.status_code == 400 + body = resp.json() + assert body["error"]["message"] == REJECTION_REASON + assert body["error"]["type"] == "BadRequestError" + + +def test_unrestricted_completions_not_blocked(): + """Ensure that if we don't shutdown any routes, the route stays.""" + app = _make_app_with_server_route() + shutdown_unsupported_routes(app, ()) + + client = TestClient(app) + resp = client.post(SERVER_ROUTE) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_route_rejection_is_idempotent(): + """Ensure that calling rejection twice doesn't break things.""" + app = _make_app_with_server_route() + restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),) + shutdown_unsupported_routes(app, restrictions) + shutdown_unsupported_routes(app, restrictions) + + client = TestClient(app) + resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"}) + assert resp.status_code == 400 + body = resp.json() + assert body["error"]["message"] == REJECTION_REASON + assert body["error"]["type"] == "BadRequestError" + + +def test_invalid_route_rejection(): + """Ensure that a restriction won't crash if a route doesn't exist.""" + # NOTE: This test is for safety since the route organization is still messy. + # Since the restriction needs a valid OmniServingCapability, its implied the route + # is a valid vLLM route, but was already removed from the app by earlier processing. + app = FastAPI() + restrictions = (EndpointRestriction(OmniServingCapability.COMPLETIONS, REJECTION_REASON),) + shutdown_unsupported_routes(app, restrictions) + + client = TestClient(app) + resp = client.post(SERVER_ROUTE, json={"prompt": "hello", "model": "m"}) + assert resp.status_code == 400 + body = resp.json() + assert body["error"]["message"] == REJECTION_REASON + assert body["error"]["type"] == "BadRequestError" diff --git a/tests/e2e/online_serving/test_qwen3_omni.py b/tests/e2e/online_serving/test_qwen3_omni.py index 0893ef1d2..ffddafbc0 100644 --- a/tests/e2e/online_serving/test_qwen3_omni.py +++ b/tests/e2e/online_serving/test_qwen3_omni.py @@ -226,3 +226,27 @@ def test_thinker_prefix_caching_audio_output(omni_server, openai_client) -> None } _run_prefix_cache_check(openai_client, request_config) + + +@pytest.mark.advanced_model +@pytest.mark.core_model +@pytest.mark.omni +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_completions_rejected_for_thinker_talker(omni_server, openai_client) -> None: + """Ensure Thinker-talker models reject /v1/completions; we do this because the + thinker-talker handoff implementations currently use ChatML <|im_start|> and + <|im_end|> markers to segment the input sequence; when we don't have them, + the talker does not get any embeddings, which breaks the server. + """ + responses = openai_client.send_completions_http_request( + { + "json": { + "model": omni_server.model, + "prompt": "Hello, how are you?", + "max_tokens": 10, + }, + }, + err_code=400, + ) + assert not responses[0].success diff --git a/tests/entrypoints/test_omni_entrypoints.py b/tests/entrypoints/test_omni_entrypoints.py index 8f647138b..4da82390b 100644 --- a/tests/entrypoints/test_omni_entrypoints.py +++ b/tests/entrypoints/test_omni_entrypoints.py @@ -107,6 +107,7 @@ class FakeAsyncOmniEngine: self.stage_vllm_configs = [None for _ in range(self.num_stages)] self.output_processors = [SimpleNamespace(tokenizer=None) for _ in range(self.num_stages)] self.input_processor = None + self.endpoint_restrictions = () self.output_q: queue.Queue[Any] = queue.Queue() self.submitted: list[dict[str, Any]] = [] diff --git a/tests/entrypoints/test_resolve_dreamzero_config.py b/tests/entrypoints/test_resolve_dreamzero_config.py index 374dcf7fa..e699edde4 100644 --- a/tests/entrypoints/test_resolve_dreamzero_config.py +++ b/tests/entrypoints/test_resolve_dreamzero_config.py @@ -24,8 +24,8 @@ def test_dreamzero_vla_resolves_to_dreamzero_config(monkeypatch): def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch): monkeypatch.setattr( - "vllm_omni.config.config_factory.StageConfigFactory._auto_detect_model_type", - classmethod(lambda _cls, _model, trust_remote_code=True: ("vla", None)), + "vllm_omni.config.config_factory.StageConfigFactory._try_infer_model_type", + classmethod(lambda _cls, model, trust_remote_code=True: "vla"), ) monkeypatch.setattr( "vllm_omni.diffusion.utils.hf_utils._looks_like_dreamzero", diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py index 51ee981cb..f33489578 100644 --- a/tests/helpers/runtime.py +++ b/tests/helpers/runtime.py @@ -1064,6 +1064,29 @@ class OpenAIClientHandler: ) return [resp] + def send_completions_http_request( + self, + request_config: dict[str, Any], + *, + err_code: int | tuple[int, ...] | list[int] | None = None, + err_message: str | tuple[str, ...] | list[str] | None = None, + ) -> list[HttpResponse]: + """POST ``/v1/completions`` with ``json`` or ``raw_body``.""" + # TODO (Alex): A lot of these helpers should be consolidated as they differ only by endpoint + cfg = _merge_http_expectation_kwargs( + request_config, + err_code=err_code, + err_message=err_message, + ) + r = self._post_json_endpoint("/v1/completions", cfg, default_timeout=120.0) + resp = self._http_response_from_requests(r) + assert_http_error( + resp, + err_code=cfg.get("err_code"), + err_message=cfg.get("err_message"), + ) + return [resp] + def send_omni_sleep_http_request( self, request_config: dict[str, Any], diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 717b9a5d7..6cf288b4c 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -16,6 +16,7 @@ from transformers import PretrainedConfig, Qwen3OmniMoeConfig from tests.helpers.stage_config import get_deploy_config_path, get_deploy_config_stage from vllm_omni.config.config_factory import StageConfigFactory +from vllm_omni.config.endpoint_policy import EndpointRestriction, OmniServingCapability from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, register_pipeline from vllm_omni.config.stage_config import ( DeployConfig, @@ -39,6 +40,15 @@ from vllm_omni.engine.arg_utils import SHARED_FIELDS, EngineArgs, internal_black pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.fixture(autouse=True) +def clear_config_factory_caches(): + """Clear cached classmethods from the StageConfigFactory to prevent test pollution.""" + yield + StageConfigFactory.get_hf_config.cache_clear() + StageConfigFactory.try_infer_model_type.cache_clear() + StageConfigFactory.get_pipeline_config.cache_clear() + + Q3_OMNI_ALL_STAGES_HF_CONFIG = Qwen3OmniMoeConfig(enable_audio_output=True) Q3_OMNI_THINKER_HF_CONFIG = Qwen3OmniMoeConfig(enable_audio_output=False) @@ -666,6 +676,43 @@ class TestPipelineRegistration: assert resolved_config is not None assert len(resolved_config) > 0 + def test_deploy_override_uses_correct_endpoint_restrictions(self, clean_pipeline_registry, tmp_path): + """Ensure endpoint restrictions must come from the final pipeline + after deploy config overrides, not the auto-detected pipeline. + """ + # Register two pipeline configs, where one has an endpoint restriction, and one doesn't + restriction = EndpointRestriction( + OmniServingCapability.COMPLETIONS, + "pipeline_a blocks completions", + ) + pipe_a = PipelineConfig(model_type="detect_type", endpoint_restrictions=(restriction,)) + pipe_b = PipelineConfig(model_type="override_type", endpoint_restrictions=()) + register_pipeline(pipe_a) + register_pipeline(pipe_b) + + # Create a config with the autodetected type, and write the + # deploy config specifying the override type to a temp path + class FakeConfig(PretrainedConfig): + model_type = "detect_type" + + deploy_yaml = tmp_path / "override.yaml" + deploy_yaml.write_text("pipeline: override_type\n") + + # Get the endpoint restrictions, passing the deploy config with the override + # type + patching the config for the detected type. Ensure that the endpoint + # restrictions correspond to the type in the deploy config. + with patch( + "vllm_omni.config.config_factory.get_config", + return_value=FakeConfig(), + ): + restrictions = StageConfigFactory.get_pipeline_endpoint_restrictions( + model="fake/model", + trust_remote_code=False, + deploy_config_path=str(deploy_yaml), + ) + + assert restrictions == () + class TestResolveScheduler: def test_all_execution_types_handled(self): diff --git a/vllm_omni/config/config_factory.py b/vllm_omni/config/config_factory.py index 20c32c5bc..4ab526a8b 100644 --- a/vllm_omni/config/config_factory.py +++ b/vllm_omni/config/config_factory.py @@ -5,6 +5,7 @@ from __future__ import annotations import dataclasses +import functools from dataclasses import asdict from pathlib import Path from typing import Any @@ -14,6 +15,7 @@ from vllm.logger import init_logger from vllm.transformers_utils.config import get_config from vllm.transformers_utils.repo_utils import get_hf_file_to_dict +from vllm_omni.config.endpoint_policy import EndpointRestriction from vllm_omni.config.pipeline_registry import OMNI_PIPELINES from vllm_omni.config.stage_config import ( _DEPLOY_DIR, @@ -46,83 +48,174 @@ class StageConfigFactory: """ @classmethod - def create_from_model( + def get_pipeline_endpoint_restrictions( cls, model: str, - cli_overrides: dict[str, Any] | None = None, - deploy_config_path: str | None = None, - **deprecated_kwargs: Any, - ) -> list[StageConfig] | None: - """Load pipeline + deploy config, merge with CLI overrides. + trust_remote_code: bool, + deploy_config_path: str | None, + ) -> tuple[EndpointRestriction, ...]: + """Given a model string, determine the corresponding endpoint restrictions. - Checks OMNI_PIPELINES first, since supported models should be explicitly - registered. If a model is not registered in OMNI_PIPELINES, tries to fall - back to using the Transformers config & finding pipelines that have overlapping - supported architectures. + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + deploy_config_path: Optional path to the deploy config for the pipeline. + + Returns: + A tuple of model specific endpoint restrictions. """ - if cli_overrides is None: - cli_overrides = {} + pipeline_cfg = StageConfigFactory.get_pipeline_config( + model=model, + trust_remote_code=trust_remote_code, + deploy_config_path=deploy_config_path, + ) + return pipeline_cfg.endpoint_restrictions if pipeline_cfg else () - trust_remote_code = cli_overrides.get("trust_remote_code", True) - if trust_remote_code is None: - trust_remote_code = False + @classmethod + @functools.cache + def get_hf_config(cls, model: str, trust_remote_code: bool) -> PretrainedConfig | None: + """Fetch the HF config (if it exists) from the model directory. - model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code) + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + + Returns: + the model's config or None. + """ + hf_config = None + try: + return get_config(model, trust_remote_code=trust_remote_code) + except Exception as e: + logger.debug(f"`get_config` failed with exception {e}; inferred HF config is None") + return hf_config + + @classmethod + @functools.cache + def try_infer_model_type(cls, model: str, trust_remote_code: bool) -> str | None: + """Auto-detect model_type from model directory and apply any model + specific patches to get the correct model_type str. If we are unable + to infer it from the model directory, we fall back to the PipelineConfig. + + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + + Returns: + model_type as a string; may be None on failure. + """ + model_type = cls._try_infer_model_type( + model=model, + trust_remote_code=trust_remote_code, + ) if model_type == "vla": if _looks_like_dreamzero(model): model_type = "dreamzero" + return model_type - # --- 1. Explicit deploy-config pipeline override (highest precedence) --- - # A deploy YAML may set ``pipeline: `` to force routing for - # models whose HF config has a generic/colliding ``model_type`` or no - # matching architectures. Ming-omni-tts, for example, reports - # model_type="dense" with arch BailingMMNative…, which matches no - # pipeline — without this it falls through to the diffusion default and - # dies with "Model class BailingMMNativeForConditionalGeneration not - # found in diffusion model registry". Honor the key before auto-detection. - explicit_pipeline = None - if deploy_config_path: - try: - explicit_pipeline = load_deploy_config(deploy_config_path).pipeline - except Exception: - logger.exception("Failed to read 'pipeline' key from deploy config %s", deploy_config_path) - if explicit_pipeline: - pipeline_cfg = cls.resolve_pipeline_config(explicit_pipeline, hf_config) - if pipeline_cfg is not None: - return cls._create_from_registry( - explicit_pipeline, - pipeline_cfg, - cli_overrides, - deploy_config_path, - ) - logger.warning( - "Deploy config %s requested pipeline %r which is not in OMNI_PIPELINES; " - "falling back to auto-detection.", - deploy_config_path, - explicit_pipeline, - ) + @classmethod + def _try_infer_model_type(cls, model: str, trust_remote_code: bool) -> str | None: + """Auto-detect model_type from model directory. - # --- 2. Auto-detected model_type registered in OMNI_PIPELINES --- + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + + Returns: + model_type as a string; may be None on failure. + """ + hf_config = cls.get_hf_config( + model=model, + trust_remote_code=trust_remote_code, + ) + if hf_config is not None: + return hf_config.model_type + + # Fallback: read config.json directly for custom model types that + # are not registered with transformers (e.g. qwen3_tts). + try: + config_dict = get_hf_file_to_dict("config.json", model, revision=None) + if config_dict: + if "model_type" in config_dict: + return config_dict["model_type"] + # VoxCPM2-style configs use singular ``architecture`` rather + # than HF's standard ``model_type`` / ``architectures``. Accept + # it as a fallback so the pipeline registry can still match. + if "architecture" in config_dict and isinstance(config_dict["architecture"], str): + return config_dict["architecture"] + except Exception as e: + logger.debug(f"Failed to auto-detect model type for {model}: {e}") + + # Fallback for diffusers-style models: check model_index.json. + # Some models (e.g. GLM-Image) have no root config.json but ship a + # model_index.json with _class_name that maps to a pipeline key via + # PipelineConfig.diffusers_class_name. + try: + model_index = get_hf_file_to_dict("model_index.json", model, revision=None) + if model_index and "_class_name" in model_index: + class_name = model_index["_class_name"] + for obj in OMNI_PIPELINES.values(): + # If we have a resolver, call it with the optional hf_config + # to get the default pipeline config for this key + pipeline_cfg = obj(hf_config) if callable(obj) else obj + if pipeline_cfg is not None and pipeline_cfg.diffusers_class_name == class_name: + logger.info( + "Detected pipeline %r from model_index.json (_class_name=%r)", + pipeline_cfg.model_type, + class_name, + ) + return pipeline_cfg.model_type + except Exception as e: + logger.debug(f"Failed to detect model type for diffusers-style models: {e}") + + # Final fallback: some models (e.g. CosyVoice3) ship an empty + # config.json and rely on naming conventions. Match the model path + # basename against registered pipeline keys — longest match wins + # so "cosyvoice3" (length 10) beats "cosyvoice" (length 9). + model_lower = model.lower().replace("-", "").replace("_", "") + best: str | None = None + best_len = 0 + for registered_key in OMNI_PIPELINES.keys(): + candidate = registered_key.lower().replace("-", "").replace("_", "") + if candidate and candidate in model_lower and len(candidate) > best_len: + best = registered_key + best_len = len(candidate) + if best is not None: + return best + + return None + + @classmethod + @functools.cache + def get_pipeline_config( + cls, + model: str, + trust_remote_code: bool, + deploy_config_path: str | None = None, + ) -> PipelineConfig | None: + """Resolve the PipelineConfig for a model path/name.""" + model_type = cls.try_infer_model_type(model=model, trust_remote_code=trust_remote_code) + hf_config = cls.get_hf_config(model=model, trust_remote_code=trust_remote_code) + + # Resolve the deploy config & check if the user set the pipeline; + # If the pipeline is explicitly set, it takes highest priority + deploy_config_pipe = cls._get_deploy_override_pipe_config(hf_config, deploy_config_path) + if deploy_config_pipe is not None: + return deploy_config_pipe + + # Pipeline isn't set in the yaml spec, so we need infer it ourselves. if model_type and model_type in OMNI_PIPELINES: pipeline_cfg = cls.resolve_pipeline_config(model_type, hf_config) if pipeline_cfg is not None: - return cls._create_from_registry( - model_type, - pipeline_cfg, - cli_overrides, - deploy_config_path, - ) + return pipeline_cfg - # --- HF architecture fallback: some models report a generic - # model_type that collides with another model. Match by the - # hf_architectures declared on each registered PipelineConfig. if hf_config is not None: - logger.warning("Inferred model type %s is not registered to an Omni pipeline", model_type) + if model_type is not None: + logger.warning("Inferred model type %s is not registered to an Omni pipeline", model_type) hf_archs = set(getattr(hf_config, "architectures", []) or []) if hf_archs: for registered in OMNI_PIPELINES.values(): pipeline_cfg = registered if isinstance(registered, PipelineConfig) else registered(hf_config) - # Resolvers that get configs of the incorrect type should return None if pipeline_cfg is None: continue predicate = pipeline_cfg.hf_config_predicate @@ -143,36 +236,61 @@ class StageConfigFactory: pipeline_cfg.model_type, ) continue - if isinstance(pipeline_cfg, PipelineConfig) and hf_archs.intersection( pipeline_cfg.hf_architectures ): - return cls._create_from_registry( - pipeline_cfg.model_type, - pipeline_cfg, - cli_overrides, - deploy_config_path, - ) + return pipeline_cfg + return None - # --- Explicit deploy-config pipeline --- - # When auto-detection above resolves nothing (generic/missing HF model_type - # and no matching architecture), honor an explicit pipeline key in the deploy config - if deploy_config_path is not None: - deploy_path = Path(deploy_config_path) - if deploy_path.exists(): - deploy_cfg = load_deploy_config(deploy_path) - if deploy_cfg.pipeline: - pipeline_cfg = cls.resolve_pipeline_config(deploy_cfg.pipeline, hf_config) - if pipeline_cfg is not None: - return cls._create_from_registry( - pipeline_cfg.model_type, - pipeline_cfg, - cli_overrides, - deploy_config_path, - ) + @classmethod + def _get_deploy_override_pipe_config( + cls, + hf_config: PretrainedConfig | None, + deploy_config_path: str | None, + ) -> PipelineConfig | None: + """Load the deploy config and extract + resolve its pipeline field.""" + if deploy_config_path is None: + return None - # Not in the pipeline registry — let the caller fall back to the - # legacy ``stage_configs/*.yaml`` path (resolve_model_config_path). + deploy_path = Path(deploy_config_path) + if deploy_path.exists(): + deploy_cfg = load_deploy_config(deploy_path) + if deploy_cfg.pipeline is not None: + return cls.resolve_pipeline_config(deploy_cfg.pipeline, hf_config) + return None + + @classmethod + def create_from_model( + cls, + model: str, + *, + trust_remote_code: bool = False, + cli_overrides: dict[str, Any] | None = None, + deploy_config_path: str | None = None, + **deprecated_kwargs: Any, + ) -> list[StageConfig] | None: + """Load pipeline + deploy config, merge with CLI overrides. + + Checks OMNI_PIPELINES first, since supported models should be explicitly + registered. If a model is not registered in OMNI_PIPELINES, tries to fall + back to using the Transformers config & finding pipelines that have overlapping + supported architectures. + """ + if cli_overrides is None: + cli_overrides = {} + + pipeline_cfg = cls.get_pipeline_config( + model=model, + trust_remote_code=trust_remote_code, + deploy_config_path=deploy_config_path, + ) + if pipeline_cfg is not None: + return cls._create_from_registry( + pipeline_cfg.model_type, + pipeline_cfg, + cli_overrides, + deploy_config_path, + ) return None @classmethod @@ -288,79 +406,6 @@ class StageConfigFactory: return [config_dict] - @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. - - Args: - model: Model name or path. - trust_remote_code: Whether to trust remote code for HF config loading. - - Returns: - Tuple of (model_type, hf_config). Both may be None on failure. - """ - hf_config = None - - try: - hf_config = get_config(model, trust_remote_code=trust_remote_code) - return hf_config.model_type, hf_config - except Exception as e: - logger.debug(f"`get_config` failed for {e}; Falling back to raw config.json path") - - # Fallback: read config.json directly for custom model types that - # are not registered with transformers (e.g. qwen3_tts). - try: - config_dict = get_hf_file_to_dict("config.json", model, revision=None) - if config_dict: - if "model_type" in config_dict: - return config_dict["model_type"], None - # VoxCPM2-style configs use singular ``architecture`` rather - # than HF's standard ``model_type`` / ``architectures``. Accept - # it as a fallback so the pipeline registry can still match. - if "architecture" in config_dict and isinstance(config_dict["architecture"], str): - return config_dict["architecture"], None - except Exception as e: - logger.debug(f"Failed to auto-detect model type for {model}: {e}") - - # Fallback for diffusers-style models: check model_index.json. - # Some models (e.g. GLM-Image) have no root config.json but ship a - # model_index.json with _class_name that maps to a pipeline key via - # PipelineConfig.diffusers_class_name. - try: - model_index = get_hf_file_to_dict("model_index.json", model, revision=None) - if model_index and "_class_name" in model_index: - class_name = model_index["_class_name"] - for obj in OMNI_PIPELINES.values(): - # If we have a resolver, call it with the optional hf_config - # to get the default pipeline config for this key - pipeline_cfg = obj(hf_config) if callable(obj) else obj - if pipeline_cfg is not None and pipeline_cfg.diffusers_class_name == class_name: - logger.info( - "Detected pipeline %r from model_index.json (_class_name=%r)", - pipeline_cfg.model_type, - class_name, - ) - return pipeline_cfg.model_type, None - except Exception as e: - logger.debug(f"Failed to detect model type for diffusers-style models: {e}") - - # Final fallback: some models (e.g. CosyVoice3) ship an empty - # config.json and rely on naming conventions. Match the model path - # basename against registered pipeline keys — longest match wins - # so "cosyvoice3" (length 10) beats "cosyvoice" (length 9). - model_lower = model.lower().replace("-", "").replace("_", "") - best: str | None = None - best_len = 0 - for registered_key in OMNI_PIPELINES.keys(): - candidate = registered_key.lower().replace("-", "").replace("_", "") - if candidate and candidate in model_lower and len(candidate) > best_len: - best = registered_key - best_len = len(candidate) - if best is not None: - return best, None - - return None, None - @classmethod def _merge_cli_overrides( cls, @@ -381,6 +426,7 @@ class StageConfigFactory: """Given a model type, resolve to the pipeline to be used. If the pipeline maps to a callable we resolve based on the HF config.""" if model_type not in OMNI_PIPELINES: + logger.warning("Model type %s is not registered to OMNI_PIPELINES", model_type) return None obj = OMNI_PIPELINES[model_type] return obj(hf_config) if callable(obj) else obj diff --git a/vllm_omni/config/endpoint_policy.py b/vllm_omni/config/endpoint_policy.py new file mode 100644 index 000000000..5a1449614 --- /dev/null +++ b/vllm_omni/config/endpoint_policy.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Endpoint restriction policy for omni pipelines.""" + +from dataclasses import dataclass +from enum import Enum +from typing import NamedTuple + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from vllm.entrypoints.serve.utils.error_response import create_error_response + + +class RouteTarget(NamedTuple): + """A server path & supported methods.""" + + path: str + methods: frozenset[str] + + +class OmniServingCapability(Enum): + """Serving capabilities that pipelines can shut down.""" + + CHAT_COMPLETIONS_BATCH = RouteTarget("/v1/chat/completions/batch", frozenset({"POST"})) + COMPLETIONS = RouteTarget("/v1/completions", frozenset({"POST"})) + + @property + def path(self) -> str: + return self.value.path + + @property + def methods(self) -> frozenset[str]: + return self.value.methods + + +@dataclass(frozen=True) +class EndpointRestriction: + capability: OmniServingCapability + reason: str + + +# Routes that are not supported for any model, but are supported in vLLM. +# This is only temporary to avoid 500s for batched chat completions. +UNSUPPORTED_ROUTES: tuple[EndpointRestriction, ...] = ( + EndpointRestriction( + OmniServingCapability.CHAT_COMPLETIONS_BATCH, + "Batched chat completions are not yet supported by vLLM Omni.", + ), +) + + +def build_rejection_handler(reason: str): + """Build a rejection handler for a given endpoint for the provided reason.""" + + async def rejection_handler(raw_request: Request): + error = create_error_response(message=reason) + return JSONResponse( + content=error.model_dump(), + status_code=error.error.code, + ) + + return rejection_handler + + +def shutdown_unsupported_routes( + app: FastAPI, + endpoint_restrictions: tuple[EndpointRestriction, ...], +): + """Given an initialized FastAPI server instance and a set of model specific endpoint + restrictions, remove the restricted routes and patch a handler that returns 400. + """ + from vllm_omni.entrypoints.openai.api_server import _remove_route_from_app + + # Generally these should not overlap since there is no point. If they do, + # we use the reason message in UNSUPPORTED_ROUTES, for consistent error messages. + restricted_endpoints = (*endpoint_restrictions, *UNSUPPORTED_ROUTES) + + for end_restrict in restricted_endpoints: + capability = end_restrict.capability + # Remove the route from the app + _remove_route_from_app(app, capability.path, capability.methods) + + # Patch the bad request error with the model specific + # reason for shutting down this endpoint + rejection_handler = build_rejection_handler(end_restrict.reason) + + app.add_api_route( + capability.path, + rejection_handler, + methods=list(capability.methods), + ) diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index 5447fde7a..6b8a09423 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -18,6 +18,7 @@ from transformers import PretrainedConfig from vllm.logger import init_logger from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler +from vllm_omni.config.endpoint_policy import EndpointRestriction 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 @@ -268,6 +269,7 @@ class PipelineConfig: # this value to auto-detect the pipeline. Only needed for diffusers-style # multi-component repos (e.g. GLM-Image). ``None`` = not a diffusers model. diffusers_class_name: str | None = None + endpoint_restrictions: tuple[EndpointRestriction, ...] = () def get_stage(self, stage_id: int) -> StagePipelineConfig | None: """Look up a stage by its ID.""" diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 77e0a8405..3c898ee75 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -30,6 +30,7 @@ from vllm.logger import init_logger from vllm.v1.engine import EngineCoreRequest from vllm.v1.engine.input_processor import InputProcessor +from vllm_omni.config.config_factory import StageConfigFactory from vllm_omni.config.stage_config import strip_parent_engine_args from vllm_omni.diffusion.data import DiffusionParallelConfig, parse_attention_config from vllm_omni.diffusion.diffusion_engine import supports_audio_output @@ -216,6 +217,7 @@ class AsyncOmniEngine: transfer_emitter: Any = None, log_stats: bool = False, tokenizer: str | None = None, + trust_remote_code: bool = False, **kwargs: Any, ) -> None: self.model = model @@ -273,6 +275,17 @@ class AsyncOmniEngine: self._omni_master_port, ) + # Stage resolution pops deploy_config, so get the pipeline endpoint + # restriction beforehand. TODO (Alex) make this cleaner and refactor + # stage config resolution to remove kwargs hacks. + deploy_config_path = kwargs.get("deploy_config") + self.endpoint_restrictions = StageConfigFactory.get_pipeline_endpoint_restrictions( + model=model, + trust_remote_code=trust_remote_code, + deploy_config_path=deploy_config_path, + ) + + kwargs["trust_remote_code"] = trust_remote_code self.config_path, self.stage_configs = self._resolve_stage_configs(model, kwargs) self.num_stages = len(self.stage_configs) diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 3fe0f8165..74911af9e 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -148,6 +148,7 @@ class AsyncOmni(EngineClient, OmniBase): self.config_path = self.engine.config_path self.tts_max_instructions_length = kwargs.get("tts_max_instructions_length", None) self.input_processor = self.engine.input_processor + self.endpoint_restrictions = self.engine.endpoint_restrictions stage_index = self._get_comprehension_stage_index() if stage_index is None: diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 991e702e3..7e1153617 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.config.endpoint_policy import shutdown_unsupported_routes from vllm_omni.diffusion.models.interface import ReferenceVideoDecodeSpec from vllm_omni.entrypoints.async_omni import AsyncOmni from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError @@ -259,7 +260,7 @@ async def _get_vllm_config(engine_client: EngineClient) -> Any: return getattr(engine_client, "vllm_config", None) -def _remove_route_from_app(app, path: str, methods: set[str] | None = None): +def _remove_route_from_app(app, path: str, methods: frozenset[str] | None = None): """Remove a route from the app by path and optionally by methods. OMNI: used to override upstream /v1/chat/completions with omni behavior. @@ -509,6 +510,12 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None, await omni_init_app_state(engine_client, app.state, args) + # After initializing the app state, shut down any endpoints that are model specific + if hasattr(engine_client, "endpoint_restrictions"): + shutdown_unsupported_routes(app, engine_client.endpoint_restrictions) + else: + logger.warning("engine client has no endpoint restrictions attribute") + # Start background processes await STORAGE_MANAGER.start() diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index 961638e14..5ecbaa9c9 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -345,6 +345,7 @@ def load_stage_configs_from_model( stages = StageConfigFactory.create_from_model( model, + trust_remote_code=cli_overrides.get("trust_remote_code", False), cli_overrides=cli_overrides, deploy_config_path=deploy_config_path, ) diff --git a/vllm_omni/model_executor/models/qwen3_omni/pipeline.py b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py index 07debc724..137e4e29d 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/pipeline.py +++ b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py @@ -9,6 +9,7 @@ Stage 2: Code2Wav — RVQ codes → audio waveform from transformers import Qwen3OmniMoeConfig +from vllm_omni.config.endpoint_policy import EndpointRestriction, OmniServingCapability from vllm_omni.config.stage_config import ( PipelineConfig, StageExecutionType, @@ -21,6 +22,12 @@ _PROC = "vllm_omni.model_executor.stage_input_processors.qwen3_omni" QWEN3_OMNI_PIPELINE = PipelineConfig( model_type="qwen3_omni_moe", model_arch="Qwen3OmniMoeForConditionalGeneration", + endpoint_restrictions=( + EndpointRestriction( + OmniServingCapability.COMPLETIONS, + "Qwen3-Omni requires chat template structure for thinker-talker handoff. Use /v1/chat/completions instead.", + ), + ), stages=( StagePipelineConfig( stage_id=0, From 267b49b88001e17204515e7483d168caea482fd0 Mon Sep 17 00:00:00 2001 From: leo <92263211+napleon-liu@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:52:48 +0800 Subject: [PATCH 047/101] Fix Voxtral TTS feedback and short ASR checks (#4954) Signed-off-by: leo --- tests/helpers/assertions.py | 51 ++++++++---- tests/helpers/test_assertions.py | 25 ++++++ .../models/voxtral_tts/test_tts_preprocess.py | 78 +++++++++++++++++++ .../models/voxtral_tts/voxtral_tts.py | 7 +- 4 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 tests/helpers/test_assertions.py create mode 100644 tests/model_executor/models/voxtral_tts/test_tts_preprocess.py diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py index b3abea735..11e86ed8c 100644 --- a/tests/helpers/assertions.py +++ b/tests/helpers/assertions.py @@ -19,19 +19,20 @@ from PIL import Image from tests.helpers.media import ( convert_audio_bytes_to_text, cosine_similarity_text, + preprocess_text, ) _GENDER_PIPELINE = None _GENDER_PIPELINE_LOCK = threading.Lock() # Transcript gates default to whisper ``small`` for speed. ``small`` mishears a # short TTS clip ~0.5% of the time (e.g. "Hello"->"fellow", or hallucinating a -# leading SFX token), which flakes the deterministic similarity gate. A test can -# opt in to ASR escalation by setting ``transcript_escalation_model`` to a -# whisper model name (e.g. ``"large-v3"``) in its request_config: on a failed -# fast pass the clip is re-transcribed with that stronger ASR before the test -# fails, so a weak-ASR mishear is rescued while a genuine model artifact still -# fails (the strong ASR mismatches too). Tests that do not opt in keep the -# strict behaviour. +# leading SFX token), which flakes the deterministic similarity gate. Short +# clips first get a conservative containment fallback for minor ASR repeats/noise. +# A test can also opt in to ASR escalation by setting +# ``transcript_escalation_model`` to a whisper model name (e.g. ``"large-v3"``) +# in its request_config: on a failed fast pass the clip is re-transcribed with +# that stronger ASR before the test fails, so a weak-ASR mishear is rescued while +# a genuine model artifact still fails (the strong ASR mismatches too). _PCM_SPEECH_SAMPLE_RATE_HZ = 24_000 _MIN_PCM_SPEECH_HNR_DB = 1.0 _PRESET_VOICE_GENDER_MAP: dict[str, str] = { @@ -43,6 +44,23 @@ _PRESET_VOICE_GENDER_MAP: dict[str, str] = { } +def _short_transcript_contains_expected(transcript: str, expected: str) -> bool: + """Allow minor ASR repeats/noise for very short speech clips.""" + transcript_clean = preprocess_text(transcript) + expected_clean = preprocess_text(expected) + if not transcript_clean or not expected_clean: + return False + + transcript_words = transcript_clean.split() + expected_words = expected_clean.split() + if not transcript_words or not expected_words: + return False + + short_text = min(len(transcript_clean), len(expected_clean)) <= 15 + small_word_delta = len(transcript_words) <= len(expected_words) + 2 + return short_text and small_word_delta and expected_clean in transcript_clean + + def assert_image_diffusion_response( response: "DiffusionResponse", request_config: dict[str, Any], @@ -620,13 +638,16 @@ def _assert_transcript_matches( ``transcript`` is the fast whisper-``small`` result. If it clears ``threshold`` the check passes immediately. + If the cosine check fails, very short clips get a conservative containment + fallback that accepts minor ASR repeats/noise only when the expected text is + still present and the transcript has few extra words. + When ``escalation_model`` is set (opt-in via the ``transcript_escalation_model`` - request_config key) and the fast check fails, the clip is re-transcribed with - that stronger ASR and the assertion is decided on its verdict -- so a weak - whisper-``small`` mishear on a short clip does not flake the gate, while a - genuine model artifact still fails (the strong ASR mismatches too). When - ``escalation_model`` is ``None`` the original strict behaviour is preserved, - so other tests are unaffected. + request_config key) and the fast check plus containment fallback fail, the + clip is re-transcribed with that stronger ASR and the assertion is decided on + its verdict -- so a weak whisper-``small`` mishear on a short clip does not + flake the gate, while a genuine model artifact still fails (the strong ASR + mismatches too). """ expected = str(expected_text).strip().lower() similarity = cosine_similarity_text(transcript.strip().lower(), expected) @@ -634,6 +655,10 @@ def _assert_transcript_matches( if similarity > threshold: return + if _short_transcript_contains_expected(transcript, expected): + print("short speech containment check passed") + return + if escalation_model and audio_bytes: print( f"whisper-small below threshold ({similarity:.2f} <= {threshold}); " diff --git a/tests/helpers/test_assertions.py b/tests/helpers/test_assertions.py new file mode 100644 index 000000000..0bfa7f593 --- /dev/null +++ b/tests/helpers/test_assertions.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from tests.helpers.assertions import _assert_transcript_matches + + +def test_short_transcript_repeat_passes_containment_fallback(): + _assert_transcript_matches( + " How... how are you?", + audio_bytes=None, + expected_text="how are you", + threshold=0.9, + ) + + +def test_short_transcript_unrelated_text_still_fails(): + with pytest.raises(AssertionError, match="Transcript doesn't match input"): + _assert_transcript_matches( + " I don't know, sorry.", + audio_bytes=None, + expected_text="how are you", + threshold=0.9, + ) diff --git a/tests/model_executor/models/voxtral_tts/test_tts_preprocess.py b/tests/model_executor/models/voxtral_tts/test_tts_preprocess.py new file mode 100644 index 000000000..399a7ecbd --- /dev/null +++ b/tests/model_executor/models/voxtral_tts/test_tts_preprocess.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import functools + +import pytest +import torch +import torch.nn as nn + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@functools.lru_cache(maxsize=1) +def _voxtral_tts_model_cls(): + from tests.model_executor.helpers import bootstrap_vllm_layer_custom_op_modules + + bootstrap_vllm_layer_custom_op_modules() + import vllm.model_executor.models.utils # noqa: F401 + + from vllm_omni.model_executor.models.voxtral_tts.voxtral_tts import ( + VoxtralTTSForConditionalGeneration, + ) + + return VoxtralTTSForConditionalGeneration + + +class FakeAudioGeneration(nn.Module): + def __init__(self): + super().__init__() + self.embed_multimodal_calls = [] + + def embed_multimodal(self, **kwargs): + self.embed_multimodal_calls.append(kwargs) + return [torch.full((1, 4), 7.0)] + + +def _make_voxtral_tts_model(): + model_cls = _voxtral_tts_model_cls() + model = model_cls.__new__(model_cls) + nn.Module.__init__(model) + model.model_stage = "audio_generation" + model.model = FakeAudioGeneration() + model._audio_token_id = 42 + return model + + +def test_tts_preprocess_consumes_nested_codes_audio_feedback(): + model = _make_voxtral_tts_model() + input_ids = torch.tensor([model._audio_token_id]) + input_embeds = torch.zeros((1, 4)) + audio_tokens = torch.tensor([[1, 2, 3, 4]]) + + _, output_embeds, _ = model.tts_preprocess( + input_ids=input_ids, + input_embeds=input_embeds, + codes={"audio": audio_tokens}, + ) + + assert len(model.model.embed_multimodal_calls) == 1 + torch.testing.assert_close(model.model.embed_multimodal_calls[0]["audio_tokens"], audio_tokens) + torch.testing.assert_close(output_embeds, torch.full((1, 4), 7.0)) + + +def test_tts_preprocess_keeps_legacy_top_level_audio_feedback(): + model = _make_voxtral_tts_model() + input_ids = torch.tensor([model._audio_token_id]) + input_embeds = torch.zeros((1, 4)) + audio_tokens = torch.tensor([[5, 6, 7, 8]]) + + _, output_embeds, _ = model.tts_preprocess( + input_ids=input_ids, + input_embeds=input_embeds, + audio=audio_tokens, + ) + + assert len(model.model.embed_multimodal_calls) == 1 + torch.testing.assert_close(model.model.embed_multimodal_calls[0]["audio_tokens"], audio_tokens) + torch.testing.assert_close(output_embeds, torch.full((1, 4), 7.0)) diff --git a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py index f262ede61..40d4ec620 100644 --- a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py +++ b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py @@ -209,7 +209,12 @@ class VoxtralTTSForConditionalGeneration( def tts_preprocess(self, input_ids: torch.Tensor, input_embeds: torch.Tensor, **info_dict: dict | None): self.post_process_idx = 0 - audio_tokens = info_dict.pop("audio", None) + codes = info_dict.get("codes") + audio_tokens = codes.get("audio") if isinstance(codes, Mapping) else None + if audio_tokens is None: + # Backward compatible with request state written before #4527 moved + # Voxtral TTS feedback frames under ``codes.audio``. + audio_tokens = info_dict.pop("audio", None) if audio_tokens is not None: kwargs = {"audio_tokens": audio_tokens.to(input_ids.device)} multimodal_embeddings = self.model.embed_multimodal(**kwargs) From da15a4cb1cfeae3ca02bc075bbe64fe64de59820 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:24:31 -0700 Subject: [PATCH 048/101] [Bugfix] Drop out-of-vocabulary stop ids from min-tokens masking (qwen3-tts min_tokens engine crash) (#4971) Signed-off-by: Yueqian Lin --- .../worker/test_min_tokens_stop_ids_guard.py | 115 ++++++++++++++++++ vllm_omni/deploy/qwen3_tts.yaml | 4 + .../deploy/qwen3_tts_high_concurrency.yaml | 4 + .../npu/worker/npu_ar_model_runner.py | 10 ++ vllm_omni/worker/gpu_ar_model_runner.py | 10 ++ vllm_omni/worker/sampling_utils.py | 58 +++++++++ 6 files changed, 201 insertions(+) create mode 100644 tests/worker/test_min_tokens_stop_ids_guard.py create mode 100644 vllm_omni/worker/sampling_utils.py diff --git a/tests/worker/test_min_tokens_stop_ids_guard.py b/tests/worker/test_min_tokens_stop_ids_guard.py new file mode 100644 index 000000000..5d7591c99 --- /dev/null +++ b/tests/worker/test_min_tokens_stop_ids_guard.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Guard against out-of-vocabulary stop ids in min-tokens masking (#4962). + +vLLM's input processor folds the stage tokenizer's EOS id into +``SamplingParams.all_stop_token_ids``. For AR stages whose lm_head is +narrower than the tokenizer vocabulary (codec talkers such as Qwen3-TTS: +3072 logits vs text EOS 151645), any ``min_tokens >= 1`` makes +``MinTokensLogitsProcessor`` write ``-inf`` at an out-of-range index and +the engine dies with a CUDA device-side assert. +""" + +import pytest +import torch +from vllm import SamplingParams +from vllm.v1.sample.logits_processor import ( + BatchUpdate, + LogitsProcessors, + MinTokensLogitsProcessor, +) + +from vllm_omni.worker.sampling_utils import sanitize_min_tokens_stop_ids + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +TALKER_VOCAB = 3072 +CODEC_EOS = 2150 +TEXT_EOS = 151645 + + +def _make_min_tokens_proc(stop_token_ids: list[int], eos_token_id: int | None) -> MinTokensLogitsProcessor: + """Build a MinTokensLogitsProcessor with one active request, mirroring + the production path (engine folds the tokenizer EOS via + ``update_from_generation_config``).""" + params = SamplingParams(min_tokens=2, stop_token_ids=stop_token_ids) + if eos_token_id is not None: + params.update_from_generation_config({}, eos_token_id) + proc = MinTokensLogitsProcessor(None, device=torch.device("cpu"), is_pin_memory=False) + proc.update_state( + BatchUpdate( + batch_size=1, + removed=[], + added=[(0, params, None, [])], + moved=[], + ) + ) + return proc + + +def test_oob_stop_id_crashes_without_guard(): + """Document the failure mode: the folded text EOS is out of range for a + narrow codec head and index_put_ raises (device-side assert on CUDA).""" + proc = _make_min_tokens_proc([CODEC_EOS], eos_token_id=TEXT_EOS) + logits = torch.zeros(1, TALKER_VOCAB) + with pytest.raises((IndexError, RuntimeError)): + proc.apply(logits) + + +def test_guard_filters_oob_and_keeps_in_range_mask(): + proc = _make_min_tokens_proc([CODEC_EOS], eos_token_id=TEXT_EOS) + sanitize_min_tokens_stop_ids(LogitsProcessors([proc]), TALKER_VOCAB) + + logits = torch.zeros(1, TALKER_VOCAB) + out = proc.apply(logits) + + assert out[0, CODEC_EOS] == -float("inf") + masked = (out == -float("inf")).nonzero() + assert masked.tolist() == [[0, CODEC_EOS]] + + +def test_guard_noop_when_all_stop_ids_in_range(): + """Full-width heads (e.g. CosyVoice3 talker) must be untouched: no + tensor rebuild, mask unchanged.""" + proc = _make_min_tokens_proc([CODEC_EOS], eos_token_id=None) + slice_before = proc.logits_slice + + sanitize_min_tokens_stop_ids(LogitsProcessors([proc]), TALKER_VOCAB) + + assert proc.logits_slice is slice_before + out = proc.apply(torch.zeros(1, TALKER_VOCAB)) + assert out[0, CODEC_EOS] == -float("inf") + + +def test_guard_sanitizes_params_only_once(): + """The stop-id set is shared with the request's SamplingParams, so the + second call finds nothing to drop (no repeated rebuilds/warnings).""" + proc = _make_min_tokens_proc([CODEC_EOS], eos_token_id=TEXT_EOS) + sanitize_min_tokens_stop_ids(LogitsProcessors([proc]), TALKER_VOCAB) + slice_after_first = proc.logits_slice + + sanitize_min_tokens_stop_ids(LogitsProcessors([proc]), TALKER_VOCAB) + + assert proc.logits_slice is slice_after_first + + +def test_guard_handles_multiple_requests(): + params_oob = SamplingParams(min_tokens=4, stop_token_ids=[CODEC_EOS]) + params_oob.update_from_generation_config({}, TEXT_EOS) + params_ok = SamplingParams(min_tokens=4, stop_token_ids=[7]) + proc = MinTokensLogitsProcessor(None, device=torch.device("cpu"), is_pin_memory=False) + proc.update_state( + BatchUpdate( + batch_size=2, + removed=[], + added=[(0, params_oob, None, []), (1, params_ok, None, [])], + moved=[], + ) + ) + + sanitize_min_tokens_stop_ids(LogitsProcessors([proc]), TALKER_VOCAB) + out = proc.apply(torch.zeros(2, TALKER_VOCAB)) + + assert out[0, CODEC_EOS] == -float("inf") + assert out[1, 7] == -float("inf") + assert int((out == -float("inf")).sum()) == 2 diff --git a/vllm_omni/deploy/qwen3_tts.yaml b/vllm_omni/deploy/qwen3_tts.yaml index 69085fc5a..34f0d6cdc 100644 --- a/vllm_omni/deploy/qwen3_tts.yaml +++ b/vllm_omni/deploy/qwen3_tts.yaml @@ -54,6 +54,10 @@ stages: temperature: 0.9 top_k: 50 max_tokens: 4096 + # Official qwen-tts generates the talker with min_new_tokens=2; without + # it, checkpoints that sample codec EOS on the first frame return empty + # audio (HTTP 400). See #4962. + min_tokens: 2 seed: 42 repetition_penalty: 1.05 subtalker_sampling_params: diff --git a/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml b/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml index 4fb6f9894..939396840 100644 --- a/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml +++ b/vllm_omni/deploy/qwen3_tts_high_concurrency.yaml @@ -60,6 +60,10 @@ stages: temperature: 0.9 top_k: 50 max_tokens: 4096 + # Official qwen-tts generates the talker with min_new_tokens=2; without + # it, checkpoints that sample codec EOS on the first frame return empty + # audio (HTTP 400). See #4962. + min_tokens: 2 repetition_penalty: 1.05 subtalker_sampling_params: do_sample: true diff --git a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py index a0abc7fa2..9a8a55709 100644 --- a/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py +++ b/vllm_omni/platforms/npu/worker/npu_ar_model_runner.py @@ -46,6 +46,7 @@ from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTran from vllm_omni.outputs import OmniModelRunnerOutput from vllm_omni.platforms.npu.worker.npu_model_runner import OmniNPUModelRunner from vllm_omni.utils.mm_outputs import build_mm_cpu, partition_payload_list, to_payload_element +from vllm_omni.worker.sampling_utils import sanitize_min_tokens_stop_ids def _ensure_tensor_values(payload: dict[str, object]) -> dict[str, torch.Tensor]: @@ -922,6 +923,15 @@ class NPUARModelRunner(OmniNPUModelRunner): logits_vocab = logits.shape[-1] if self.input_batch.vocab_size > logits_vocab: smd.prompt_token_ids = smd.prompt_token_ids.clamp(max=logits_vocab) + + # Drop min-tokens stop ids the head cannot emit (e.g. the text + # tokenizer EOS folded into all_stop_token_ids on a narrow codec + # talker head); they would index_put_ out of bounds (#4962). + if logits is not None: + sanitize_min_tokens_stop_ids( + self.input_batch.sampling_metadata.logitsprocs, + logits.shape[-1], + ) # -------------------------------------- Omni-new ------------------------------------------------- diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 2bf7a8af2..a5e4a9bd1 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -47,6 +47,7 @@ from vllm_omni.utils.mm_outputs import build_mm_cpu, partition_payload_list, to_ from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin from vllm_omni.worker.runner_assisted_metadata import RunnerAssistedFullAttentionMetadataRequest +from vllm_omni.worker.sampling_utils import sanitize_min_tokens_stop_ids logger = init_logger(__name__) @@ -1892,6 +1893,15 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): if self.input_batch.vocab_size > logits_vocab: smd.prompt_token_ids = smd.prompt_token_ids.clamp(max=logits_vocab) + # Drop min-tokens stop ids the head cannot emit (e.g. the text + # tokenizer EOS folded into all_stop_token_ids on a narrow codec + # talker head); they would index_put_ out of bounds (#4962). + if logits is not None: + sanitize_min_tokens_stop_ids( + self.input_batch.sampling_metadata.logitsprocs, + logits.shape[-1], + ) + with record_function_or_nullcontext("gpu_model_runner: sample"): sampler_output = self._sample(logits, spec_decode_metadata) diff --git a/vllm_omni/worker/sampling_utils.py b/vllm_omni/worker/sampling_utils.py new file mode 100644 index 000000000..c38a9bbdc --- /dev/null +++ b/vllm_omni/worker/sampling_utils.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Sampling-state guards shared by the GPU and NPU AR model runners.""" + +import torch +from vllm.logger import init_logger +from vllm.v1.sample.logits_processor import LogitsProcessors, MinTokensLogitsProcessor + +logger = init_logger(__name__) + +__all__ = ["sanitize_min_tokens_stop_ids"] + + +def sanitize_min_tokens_stop_ids(logitsprocs: LogitsProcessors, logits_vocab: int) -> None: + """Drop stop ids the model head cannot emit from min-tokens masking state. + + vLLM's input processor unconditionally folds the stage tokenizer's EOS id + into ``SamplingParams.all_stop_token_ids``. AR stages whose lm_head is + narrower than the tokenizer vocabulary (codec talkers such as Qwen3-TTS: + 3072 logits vs text EOS 151645) then crash on any ``min_tokens >= 1``: + ``MinTokensLogitsProcessor.apply`` writes ``-inf`` at an out-of-range + index and ``index_put_`` triggers a CUDA device-side assert (#4962). + + Out-of-range ids are unreachable for the head, so dropping them never + changes sampling or stopping behavior. The per-request stop-id set is + mutated in place (it is shared with the request's ``SamplingParams``), + so each request is sanitized at most once; the processor's device-side + mask slice is rebuilt only when an out-of-range id was found. + """ + for proc in logitsprocs.non_argmax_invariant: + if not isinstance(proc, MinTokensLogitsProcessor): + continue + min_toks = getattr(proc, "min_toks", None) + if not min_toks: + continue + needs_rebuild = False + for _, _, stop_tok_ids in min_toks.values(): + oob = [tok for tok in stop_tok_ids if tok >= logits_vocab] + if not oob: + continue + stop_tok_ids.difference_update(oob) + needs_rebuild = True + logger.warning_once( + "min_tokens: dropped stop token ids %s that exceed the logits vocabulary (%d); " + "the model head cannot emit them.", + str(sorted(oob)), + logits_vocab, + ) + if needs_rebuild: + reqs: list[int] = [] + tok_ids: list[int] = [] + for index, (_, _, stop_tok_ids) in min_toks.items(): + reqs.extend([index] * len(stop_tok_ids)) + tok_ids.extend(stop_tok_ids) + proc.logits_slice = ( + proc._device_tensor(reqs, torch.int32), + proc._device_tensor(tok_ids, torch.int32), + ) From 6d50348ef19444f06cd1293f8afa235c385f2b0f Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:59:41 -0700 Subject: [PATCH 049/101] [Perf][Qwen3-TTS] Skip per-step hidden-state D2H via hidden pooler payload opt-out (#4879) Signed-off-by: Yueqian Lin --- .../model_executor/models/qwen3_tts/qwen3_tts_talker.py | 6 ++++++ vllm_omni/worker/gpu_ar_model_runner.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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 347c76dfa..2d28a0730 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 @@ -309,6 +309,12 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): # tensor read; postprocess receives the tail-only slice instead, which # avoids ~18 ms merge + ~6 ms write per step (Sy0307 profile, #3665). self.requires_full_prefix_cached_hidden_states = False + # Stage 1 (code2wav) consumes runtime audio codes, not hidden states, + # so the inter-stage pooler payload never needs a CPU hidden-states + # view. Opting out lets the runner skip the per-step blocking + # ``hidden_states[:n].to("cpu")`` (measured at 24% of stage-0 on-CPU + # samples at c=64 on H20). + self.omni_pooler_payload_include_hidden = False # ``codes.audio`` is only needed for future prefix-hit reconstruction # after a request has produced codec rows. Keep per-step rows on GPU and # materialize the CPU OmniTensorPrefixCache entry once at completion. diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index a5e4a9bd1..59bfbb855 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -1309,7 +1309,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): # prefix caching but the downstream pooler payload path still # needs a CPU hidden-states view. Materialize it synchronously # in that case; the legacy behavior is preserved. - if hs_for_cache is None: + if hs_for_cache is None and self._model_omni_pooler_payload_include_hidden(): hidden_states_cpu = hidden_states[:num_tokens_unpadded].detach().to("cpu").contiguous() slot_mapping_gpu = self.input_batch.block_table[0].slot_mapping.gpu self.omni_prefix_cache.schedule_async_write( From 3a64da194c0a0b3af7448bc6b09aede36cc270eb Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:25:18 -0700 Subject: [PATCH 050/101] [Perf][Qwen3-TTS] Drop default seed from qwen3_tts.yaml to restore batched MTP sampling (#4970) Signed-off-by: Yueqian Lin --- vllm_omni/deploy/qwen3_tts.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/vllm_omni/deploy/qwen3_tts.yaml b/vllm_omni/deploy/qwen3_tts.yaml index 34f0d6cdc..4f95c1f48 100644 --- a/vllm_omni/deploy/qwen3_tts.yaml +++ b/vllm_omni/deploy/qwen3_tts.yaml @@ -58,7 +58,6 @@ stages: # it, checkpoints that sample codec EOS on the first frame return empty # audio (HTTP 400). See #4962. min_tokens: 2 - seed: 42 repetition_penalty: 1.05 subtalker_sampling_params: do_sample: true @@ -89,7 +88,6 @@ stages: top_p: 1.0 top_k: -1 max_tokens: 65536 - seed: 42 repetition_penalty: 1.0 platforms: From a5db2d839a0a20ddb0090faa5bb233280601e5eb Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Thu, 9 Jul 2026 12:10:10 +0800 Subject: [PATCH 051/101] [Diffusion] Improve HuggingFace hub kernel loading in flash_attn_hub (#4977) Signed-off-by: samithuang <285365963@qq.com> --- .../attention/backends/flash_attn_hub.py | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/vllm_omni/diffusion/attention/backends/flash_attn_hub.py b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py index bfb59b46f..bce607888 100644 --- a/vllm_omni/diffusion/attention/backends/flash_attn_hub.py +++ b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import threading from functools import partial import torch @@ -18,6 +19,33 @@ from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import ( logger = init_logger(__name__) +_hub_modules: dict[str, object] = {} +_hub_lock = threading.Lock() + + +def _load_hub_module(repo_id: str): + from kernels import get_kernel + + logger.info("Loading %s kernel from HuggingFace Hub...", repo_id) + last_error = None + for version in (1, 2, None): + try: + if version is not None: + return get_kernel(repo_id, version=version) + return get_kernel(repo_id) + except Exception as exc: + logger.info("Failed to load %s version %s: %s", repo_id, version, exc) + last_error = exc + raise RuntimeError(f"Failed to load HuggingFace Hub kernel {repo_id!r}") from last_error + + +def _get_hub_module(repo_id: str): + with _hub_lock: + if repo_id not in _hub_modules: + _hub_modules[repo_id] = _load_hub_module(repo_id) + return _hub_modules[repo_id] + + def _run_varlen_dense( varlen_func, query: torch.Tensor, @@ -88,23 +116,9 @@ class FlashAttentionHubImpl(AttentionImpl): if backend_kwargs: logger.warning("FlashAttentionHubImpl ignoring backend_kwargs: %s", list(backend_kwargs.keys())) - # Lazily get the kernel from the Hub to avoid network/disk overhead on import - from kernels import get_kernel - - logger.info("Loading flash-attn2 kernel from HuggingFace Hub...") - try: - hub_module = get_kernel("kernels-community/flash-attn2", version=1) - except Exception as e: - try: - logger.info("Failed to load version 1, attempting version 2: %s", e) - hub_module = get_kernel("kernels-community/flash-attn2", version=2) - except Exception as e2: - logger.info("Failed to load version 2, attempting default: %s", e2) - hub_module = get_kernel("kernels-community/flash-attn2") - + hub_module = _get_hub_module("kernels-community/flash-attn2") self.flash_attn_func = getattr(hub_module, "flash_attn_func", None) self.flash_attn_varlen_func = getattr(hub_module, "flash_attn_varlen_func", None) - if self.flash_attn_func is None and self.flash_attn_varlen_func is None: raise RuntimeError("Failed to load flash-attn2 kernel from HuggingFace Hub: no functions found") @@ -267,23 +281,9 @@ class FlashAttention3HubImpl(AttentionImpl): if backend_kwargs: logger.warning("FlashAttention3HubImpl ignoring backend_kwargs: %s", list(backend_kwargs.keys())) - # Lazily get the kernel from the Hub to avoid network/disk overhead on import - from kernels import get_kernel - - logger.info("Loading flash-attn3 kernel from HuggingFace Hub...") - try: - hub_module = get_kernel("kernels-community/flash-attn3", version=1) - except Exception as e: - try: - logger.info("Failed to load version 1, attempting version 2: %s", e) - hub_module = get_kernel("kernels-community/flash-attn3", version=2) - except Exception as e2: - logger.info("Failed to load version 2, attempting default: %s", e2) - hub_module = get_kernel("kernels-community/flash-attn3") - + hub_module = _get_hub_module("kernels-community/flash-attn3") self.flash_attn_func = getattr(hub_module, "flash_attn_func", None) self.flash_attn_varlen_func = getattr(hub_module, "flash_attn_varlen_func", None) - if self.flash_attn_func is None and self.flash_attn_varlen_func is None: raise RuntimeError("Failed to load flash-attn3 kernel from HuggingFace Hub: no functions found") From 690864f277a0e84573b972efb94b329463d782e9 Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Thu, 9 Jul 2026 09:03:51 -0600 Subject: [PATCH 052/101] [Tests] Tiny Diffusion Model Pattern (#4524) Signed-off-by: Alex Brooks --- .buildkite/test-merge.yml | 3 +- .buildkite/test-nightly.yml | 32 +++ .buildkite/test-ready.yml | 21 ++ pyproject.toml | 1 + .../e2e/offline_inference/test_flux2_klein.py | 37 +--- tests/e2e/online_serving/test_flux2_klein.py | 62 ------ .../test_flux2_klein_expansion.py | 163 --------------- tests/helpers/runtime.py | 22 ++- tests/model_tests/__init__.py | 0 tests/model_tests/conftest.py | 41 ++++ tests/model_tests/diffusion/__init__.py | 0 tests/model_tests/diffusion/case_filtering.py | 104 ++++++++++ tests/model_tests/diffusion/config_types.py | 187 ++++++++++++++++++ .../diffusion/diff_model_builders.py | 63 ++++++ tests/model_tests/diffusion/model_settings.py | 42 ++++ tests/model_tests/diffusion/task_runners.py | 172 ++++++++++++++++ tests/model_tests/diffusion/test_alignment.py | 98 +++++++++ .../diffusion/test_common_offline.py | 69 +++++++ .../diffusion/test_common_online.py | 75 +++++++ tests/model_tests/diffusion/utils.py | 33 ++++ 20 files changed, 962 insertions(+), 263 deletions(-) delete mode 100644 tests/e2e/online_serving/test_flux2_klein.py delete mode 100644 tests/e2e/online_serving/test_flux2_klein_expansion.py create mode 100644 tests/model_tests/__init__.py create mode 100644 tests/model_tests/conftest.py create mode 100644 tests/model_tests/diffusion/__init__.py create mode 100644 tests/model_tests/diffusion/case_filtering.py create mode 100644 tests/model_tests/diffusion/config_types.py create mode 100644 tests/model_tests/diffusion/diff_model_builders.py create mode 100644 tests/model_tests/diffusion/model_settings.py create mode 100644 tests/model_tests/diffusion/task_runners.py create mode 100644 tests/model_tests/diffusion/test_alignment.py create mode 100644 tests/model_tests/diffusion/test_common_offline.py create mode 100644 tests/model_tests/diffusion/test_common_online.py create mode 100644 tests/model_tests/diffusion/utils.py diff --git a/.buildkite/test-merge.yml b/.buildkite/test-merge.yml index b073bc542..51c4223f2 100644 --- a/.buildkite/test-merge.yml +++ b/.buildkite/test-merge.yml @@ -627,11 +627,10 @@ steps: - 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' + - pytest -s -v tests/e2e/offline_inference/test_flux2_klein.py -m 'advanced_model and cuda and diffusion' --run-level 'advanced_model' agents: queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU plugins: diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 85f078533..e3a5aa353 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -598,6 +598,38 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate + - group: ":card_index_dividers: Tiny Model Tests [Multi-GPU]" + key: nightly-tiny-model-test-group + depends_on: upload-nightly-pipeline + if: >- + build.env("NIGHTLY") == "1" || + build.pull_request.labels includes "nightly-test" + steps: + - label: ":full_moon: Tiny Model Tests · Diffusion (Multi-GPU accelerations)" + timeout_in_minutes: 30 + # NOTE: Single-GPU (core_model) tiny model tests run on the ready label, + # so nightly only adds the multi-GPU parallelism configurations. + # + # This is a bit confusing, but `full_model` is used to mark the multigpu tests, and + # --run-level `core_model` is used to indicate that we should use tiny model weights + # for the CI, since these tests should pass irrespective of whether or not we use + # the full weights or not. + commands: + - timeout 25m pytest -sv tests/model_tests/diffusion -m 'full_model and cuda' --run-level "core_model" + agents: + queue: "gpu_4_queue" # g6.12xlarge instance on AWS, has 4 L4 GPU + 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" + # Diffusion X2I suite: x2i / x2a / x2t and related non-video paths; x2v is only in "Diffusion X2V Model Test" below. - group: ":card_index_dividers: Diffusion X2I(&A&T) Model Test" key: nightly-diffusion-x2iat-group diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index d12267fe0..7e0942bf6 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -157,6 +157,27 @@ steps: - "/fsx/hf_cache:/fsx/hf_cache" + - group: ":card_index_dividers: Tiny Model Tests [Single GPU]" + depends_on: upload-ready-pipeline + steps: + - label: "Tiny Model Tests · Diffusion (base)" + commands: + - timeout 15m pytest -sv tests/model_tests/diffusion -m 'core_model and cuda' --run-level "core_model" + agents: + # Ready label only runs the cases that don't need parallelism for tiny + # model tests, which is usually just a smoke test for TTI and I2I + queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + environment: + - "HF_HOME=/fsx/hf_cache" + - "HF_TOKEN" + volumes: + - "/fsx/hf_cache:/fsx/hf_cache" + - label: "Engine&Model Executor Test" depends_on: upload-ready-pipeline commands: diff --git a/pyproject.toml b/pyproject.toml index 2a8fc9c8f..0da2d7b5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dev = [ "pytest-cov>=4.0.0", "pytest-mock>=3.10.0", "websockets>=15.0.1", + "pytest-subtests>=0.11.0", "datasets>=2.14.0", "mypy==1.11.1", "pre-commit==4.0.1", diff --git a/tests/e2e/offline_inference/test_flux2_klein.py b/tests/e2e/offline_inference/test_flux2_klein.py index 3bd815229..9656772c9 100644 --- a/tests/e2e/offline_inference/test_flux2_klein.py +++ b/tests/e2e/offline_inference/test_flux2_klein.py @@ -18,6 +18,8 @@ from tests.helpers.runtime import DiffusionResponse, OmniRunnerHandler from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform +# NOTE: Most of the flux2klein tests have migrated to the common diffusion tests. +# This model will also use the tiny model builder when run at the core model level. MODEL = "black-forest-labs/FLUX.2-klein-4B" _OMNI_RUNNER_PARAM = (MODEL, None) @@ -70,41 +72,6 @@ def _send_inpaint_with_generator( ) -# Regression test for https://github.com/vllm-project/vllm-omni/issues/3097 -@pytest.mark.advanced_model -@pytest.mark.diffusion -def test_flux2_klein_can_accept_text_inputs(omni_runner_handler: OmniRunnerHandler): - omni_runner_handler.send_diffusion_request( - { - "model": MODEL, - "prompt": "a cup of coffee on the table", - "sampling_params": OmniDiffusionSamplingParams(num_inference_steps=2, seed=42), - } - ) - - -@pytest.mark.advanced_model -@pytest.mark.diffusion -@hardware_test(res={"cuda": "L4"}, num_cards=1) -def test_flux2_klein_inpaint_basic(omni_runner_handler: OmniRunnerHandler): - input_image, mask_image = _create_test_inputs() - omni_runner_handler.send_diffusion_request( - { - "model": MODEL, - "prompt": "Fill in the masked area with a beautiful garden", - "multi_modal_data": {"image": input_image, "mask_image": mask_image}, - "sampling_params": OmniDiffusionSamplingParams( - height=_HEIGHT, - width=_WIDTH, - num_inference_steps=_NUM_INFERENCE_STEPS, - guidance_scale=0.0, - generator=torch.Generator(current_omni_platform.device_type).manual_seed(42), - num_outputs_per_prompt=1, - ), - } - ) - - @pytest.mark.advanced_model @pytest.mark.diffusion @hardware_test(res={"cuda": "L4"}, num_cards=1) diff --git a/tests/e2e/online_serving/test_flux2_klein.py b/tests/e2e/online_serving/test_flux2_klein.py deleted file mode 100644 index f8c705df2..000000000 --- a/tests/e2e/online_serving/test_flux2_klein.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Tests for Flux2 Klein; currently Dev is implemented separately, -but ideally these models will fold together in the future. -""" - -import pytest - -from tests.helpers.mark import hardware_marks -from tests.helpers.runtime import ( - OmniServer, - OmniServerParams, - OpenAIClientHandler, - dummy_messages_from_mix_data, -) - -FOUR_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "L4"}, num_cards=4) -POSITIVE_PROMPT = "A cat sitting on a windowsill" -NEGATIVE_PROMPT = "blurry, low quality" - - -# For now, we only test a good configuration for this model to keep the CI -# light, but in the near future may explore using random models to check -# cross-feature compatibility more generally. -def _get_diffusion_feature_cases(model: str): - return [ - # FP8 / Hybrid sequence parallelism - pytest.param( - OmniServerParams( - model=model, - ), - id="default", - marks=FOUR_CARD_FEATURE_MARKS, - ), - ] - - -@pytest.mark.advanced_model -@pytest.mark.core_model -@pytest.mark.diffusion -@pytest.mark.parametrize( - "omni_server", - _get_diffusion_feature_cases( - model="black-forest-labs/FLUX.2-klein-4B", - ), - indirect=True, -) -def test_text_to_image_001(omni_server: OmniServer, openai_client: OpenAIClientHandler): - messages = dummy_messages_from_mix_data(content_text=POSITIVE_PROMPT) - request_config = { - "model": omni_server.model, - "messages": messages, - "extra_body": { - "height": 512, - "width": 512, - "num_inference_steps": 2, - "negative_prompt": NEGATIVE_PROMPT, - "true_cfg_scale": 4.0, - "seed": 42, - }, - } - - openai_client.send_diffusion_request(request_config) diff --git a/tests/e2e/online_serving/test_flux2_klein_expansion.py b/tests/e2e/online_serving/test_flux2_klein_expansion.py deleted file mode 100644 index 98ccf0014..000000000 --- a/tests/e2e/online_serving/test_flux2_klein_expansion.py +++ /dev/null @@ -1,163 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -""" -End-to-end tests for Flux2 Klein inpainting in online serving mode. - -Uses /v1/images/edits endpoint which is the correct API for image inpainting. -""" - -import base64 -from io import BytesIO - -import httpx -import pytest -from PIL import Image, ImageDraw - -from tests.helpers.runtime import OmniServer, OmniServerParams - -pytestmark = [pytest.mark.full_model, pytest.mark.diffusion] - -MODEL = "black-forest-labs/FLUX.2-klein-4B" - -_HEIGHT = 512 -_WIDTH = 512 -_NUM_INFERENCE_STEPS = 4 - - -def _get_diffusion_feature_cases(model: str): - return [ - pytest.param( - OmniServerParams( - model=model, - server_args=["--tensor-parallel-size", "2", "--enable-cpu-offload"], - ), - id="tp2_cpu_offload", - ), - pytest.param( - OmniServerParams( - model=model, - server_args=["--tensor-parallel-size", "2"], - ), - id="tp2_basic", - ), - ] - - -def _image_to_base64_jpeg(image: Image.Image) -> str: - buffer = BytesIO() - image.save(buffer, format="JPEG") - buffer.seek(0) - return base64.b64encode(buffer.read()).decode("utf-8") - - -def _create_test_mask_base64(width: int = _WIDTH, height: int = _HEIGHT) -> str: - mask = Image.new("L", (width, height), 0) - draw = ImageDraw.Draw(mask) - draw.rectangle([width // 4, height // 4, width * 3 // 4, height * 3 // 4], fill=255) - return _image_to_base64_jpeg(mask) - - -def _compare_images(img1: Image.Image, img2: Image.Image) -> bool: - return list(img1.getdata()) == list(img2.getdata()) - - -def _send_edit_request(host: str, port: int, model: str, image_b64: str, mask_b64: str, prompt: str, **kwargs): - url = f"http://{host}:{port}/v1/images/edits" - files = { - "image": ("image.jpg", base64.b64decode(image_b64), "image/jpeg"), - "mask_image": ("mask.jpg", base64.b64decode(mask_b64), "image/jpeg"), - } - data = {"prompt": prompt, "model": model, **kwargs} - with httpx.Client(timeout=60.0) as client: - response = client.post(url, files=files, data=data) - response.raise_for_status() - return response.json() - - -@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) -def test_flux2_klein_inpaint_basic(omni_server: OmniServer): - input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128))) - mask_b64 = _create_test_mask_base64() - - result = _send_edit_request( - host=omni_server.host, - port=omni_server.port, - model=MODEL, - image_b64=input_image_b64, - mask_b64=mask_b64, - prompt="Fill in the masked area with a beautiful garden", - guidance_scale=1.0, - num_inference_steps=_NUM_INFERENCE_STEPS, - n=1, - seed=42, - ) - - assert "data" in result and len(result["data"]) == 1 - img_data = result["data"][0].get("b64_json") or result["data"][0].get("url", "").split(",")[-1] - img = Image.open(BytesIO(base64.b64decode(img_data))) - assert img.size == (_WIDTH, _HEIGHT) - - -@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) -def test_flux2_klein_inpaint_deterministic(omni_server: OmniServer): - input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128))) - mask_b64 = _create_test_mask_base64() - prompt = "A red flower in a field" - - result1 = _send_edit_request( - host=omni_server.host, - port=omni_server.port, - model=MODEL, - image_b64=input_image_b64, - mask_b64=mask_b64, - prompt=prompt, - guidance_scale=1.0, - num_inference_steps=_NUM_INFERENCE_STEPS, - n=1, - seed=12345, - ) - - result2 = _send_edit_request( - host=omni_server.host, - port=omni_server.port, - model=MODEL, - image_b64=input_image_b64, - mask_b64=mask_b64, - prompt=prompt, - guidance_scale=1.0, - num_inference_steps=_NUM_INFERENCE_STEPS, - n=1, - seed=12345, - ) - - img1_data = result1["data"][0].get("b64_json") or result1["data"][0].get("url", "").split(",")[-1] - img2_data = result2["data"][0].get("b64_json") or result2["data"][0].get("url", "").split(",")[-1] - - img1 = Image.open(BytesIO(base64.b64decode(img1_data))) - img2 = Image.open(BytesIO(base64.b64decode(img2_data))) - - assert _compare_images(img1, img2), ( - "Same input with same seed should produce identical output. This is critical for offline/online consistency." - ) - - -@pytest.mark.parametrize("omni_server", _get_diffusion_feature_cases(MODEL), indirect=True) -def test_flux2_klein_inpaint_multiple_outputs(omni_server: OmniServer): - input_image_b64 = _image_to_base64_jpeg(Image.new("RGB", (_WIDTH, _HEIGHT), (128, 128, 128))) - mask_b64 = _create_test_mask_base64() - - result = _send_edit_request( - host=omni_server.host, - port=omni_server.port, - model=MODEL, - image_b64=input_image_b64, - mask_b64=mask_b64, - prompt="A beautiful landscape", - guidance_scale=1.0, - num_inference_steps=_NUM_INFERENCE_STEPS, - n=2, - seed=42, - ) - - assert "data" in result and len(result["data"]) == 2 diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py index f33489578..6ca8d2608 100644 --- a/tests/helpers/runtime.py +++ b/tests/helpers/runtime.py @@ -47,6 +47,7 @@ from tests.helpers.media import ( _merge_base64_audio_to_segment, decode_b64_image, ) +from tests.model_tests.diffusion.utils import resolve_tiny_model_path from vllm_omni.config.stage_config import resolve_deploy_yaml from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt from vllm_omni.outputs import OmniRequestOutput @@ -2815,11 +2816,24 @@ def iter_omni_server( with omni_fixture_lock: params: OmniServerParams = request.param - model = model_prefix + params.model + # For now, when a tiny model is substituted, we preserve the original model + # name via --served-model-name (so that the server still accepts requests with + # the original name). We also do the same for server.model so that tests reading + # server.model send the correct name in requests. + # + # TODO: core models on this path currently do not clean up tiny models, although + # tiny model paths are deterministic, so it's not a huge footprint. Still, it would + # be ideal to cleanup consistently everywhere. + original_model = model_prefix + params.model + model = original_model + if run_level == "core_model": + model = resolve_tiny_model_path(model) port = params.port stage_config_path = stage_config_path_for_run_level(params.stage_config_path, run_level) server_args = params.server_args or [] + if model != original_model: + server_args = [*server_args, "--served-model-name", original_model] if params.use_omni and params.stage_init_timeout is not None: server_args = [*server_args, "--stage-init-timeout", str(params.stage_init_timeout)] else: @@ -2845,6 +2859,8 @@ def iter_omni_server( port=port, env_dict=params.env_dict, ) as server: + if model != original_model: + server.model = original_model print("OmniServer started successfully") yield server print("OmniServer stopping...") @@ -2868,6 +2884,8 @@ def iter_omni_server( use_omni=params.use_omni, ) ) as server: + if model != original_model: + server.model = original_model print("OmniServer started successfully") yield server print("OmniServer stopping...") @@ -2899,6 +2917,8 @@ def iter_omni_runner( extra_omni_kwargs = dict(extra) if extra is not None else {} stage_config_path = stage_config_path_for_run_level(stage_config_path, run_level) model = model_prefix + model + if run_level == "core_model": + model = resolve_tiny_model_path(model) with OmniRunner(model, seed=42, stage_configs_path=stage_config_path, **extra_omni_kwargs) as runner: print("OmniRunner started successfully") yield runner diff --git a/tests/model_tests/__init__.py b/tests/model_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/model_tests/conftest.py b/tests/model_tests/conftest.py new file mode 100644 index 000000000..502f3e685 --- /dev/null +++ b/tests/model_tests/conftest.py @@ -0,0 +1,41 @@ +from shutil import rmtree + +import pytest + +from tests.model_tests.diffusion.model_settings import DIFFUSION_TEST_SETTINGS + + +@pytest.fixture(scope="session") +def tiny_model_paths(request, run_level): + """Build or download the tiny models for the selected tests. + + At core_model level, builds tiny models via the builder function. + At advanced_model / full_model level, uses the real HF model path. + + NOTE: this is session scoped to avoid churn in tiny model creation, + but will ensure all the tiny models you need are created for the selected tests + before it starts to execute them.""" + model_paths = {} + built_paths = [] + print("Initializing tiny models...") + for item in request.session.items: + if not hasattr(item, "callspec"): + continue + model_name = item.callspec.params.get("model_name") + if model_name is None: + continue + if model_name not in model_paths: + settings = DIFFUSION_TEST_SETTINGS[model_name] + if run_level == "core_model": + print(f"Calling tiny model builder for: {model_name}") + path = settings.builder() + built_paths.append(path) + else: + print(f"Run level is {run_level}; {model_name} will use the full model") + path = settings.model + model_paths[model_name] = path + + yield model_paths + for path in built_paths: + print(f"Removing tiny model: {path}") + rmtree(path, ignore_errors=True) diff --git a/tests/model_tests/diffusion/__init__.py b/tests/model_tests/diffusion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/model_tests/diffusion/case_filtering.py b/tests/model_tests/diffusion/case_filtering.py new file mode 100644 index 000000000..a4fa5dc13 --- /dev/null +++ b/tests/model_tests/diffusion/case_filtering.py @@ -0,0 +1,104 @@ +""" +Analogous to: https://github.com/vllm-project/vllm/blob/v0.23.0/tests/models/multimodal/generation/vlm_utils/case_filtering.py + +This follows a similar pattern so that we can use `get_parametrized_options` to expand our test +configurations out, similar to the way in which vLLM expands supported input type tests. For now, +this mostly just means parametrizing over the test groups, which may be unique per model, but +doing so in this way lets us cleanly define test groups that may be heterogeneous + avoid having +excessive skip marks for features that may not be supported in every pipeline. +""" + +# Engine initialization is currently quite expensive. For efficiency, +# when we run the tests, we should try to collapse compatible accelerations, +# because even with tiny models, the overhead of spinning up a new instance +# stacks up fast. +import itertools + +import pytest + +from tests.helpers.mark import hardware_marks +from tests.model_tests.diffusion.config_types import DiffusionAccs, DiffusionModelTestOpts, get_required_device_count +from vllm_omni.platforms import current_omni_platform + +# These tests are intended to run on L4 GPUs since they test parallelism (i.e., need GPUs), +# but are intended to be smoke tests that we can properly validate without real model +# weights in the CI. +# +# As such, no DiffusionAccs may require more than 4 GPUs, since we would otherwise skip +# them in the CI. +MAX_CI_DEVICES = 4 + + +def get_test_group_marks(model_name: str, test_group: list[DiffusionAccs] | None, model_marks: list | None) -> list: + """Build the full set of pytest marks for a test group. + + For now, single device groups default to core model, and multi device groups + default to advanced_model marks. We also validate and throw if a test group exceeds + the max number of GPUs for the CI to prevent accidentally adding configurations that + would be skipped in the CI.""" + marks = list(model_marks) if model_marks is not None else [] + + required_devices = get_required_device_count(test_group) + if required_devices > MAX_CI_DEVICES: + raise ValueError( + f"Test group {test_group} for {model_name} requires {required_devices} devices, " + f"but the max CI device count is {MAX_CI_DEVICES}. " + ) + + marks.extend(hardware_marks(res={"cuda": "L4"}, num_cards=required_devices)) + # hardware_marks only adds skipif for num_cards > 1, so we currently handle the single-device case + # directly here. This should probably be handled in a more common way later on. + assert current_omni_platform is not None and current_omni_platform.device_count is not None + device_count = current_omni_platform.device_count() + if current_omni_platform.is_cuda() and device_count < required_devices: + marks.append(pytest.mark.skip(reason=f"Need {required_devices} devices, got {device_count}")) + if required_devices > 1: + marks.append(pytest.mark.full_model) + else: + marks.append(pytest.mark.core_model) + return marks + + +def get_model_parametrization(model_name: str, test_info: DiffusionModelTestOpts, online: bool): + """Given a model & its corresponding test options, build the list of pytest params + to be run for this model. The base case (no accelerations) is always included. Extra + test groups are always appended for offline tests, but are only added to the online + tests if online_base_only is overridden to False to avoid redundant testing. + """ + test_groups: list[list[DiffusionAccs] | None] = [None] + if not (online and test_info.online_base_only) and test_info.extra_test_groups: + test_groups.extend(test_info.extra_test_groups) + + return [ + pytest.param( + model_name, + test_group, + test_info.supported_tasks, + test_info.check_multi_output and test_group is None, + test_info.check_determinism and test_group is None, + id=f"{model_name}[{'+'.join(test_group)}]" if test_group else model_name, + marks=get_test_group_marks(model_name, test_group, test_info.marks), + ) + for test_group in test_groups + ] + + +def get_parametrized_options( + test_settings: dict[str, DiffusionModelTestOpts], + online: bool = False, +): + """Converts all the DiffusionModelTestOpts into an expanded list of parameters + based on which accelerations are available. + + When online=True and a model has online_base_only=True, only the base case + (no accelerations) is included for that model's parametrization to minimize + redundancy between offline / online tests. + """ + # Get a list per model type, where each entry contains a tuple of all of + # that model type's cases, then flatten them into the top level so that + # we can consume them in one mark.parametrize call. + parametrization = [ + get_model_parametrization(model_name, test_info, online=online) + for model_name, test_info in test_settings.items() + ] + return list(itertools.chain(*parametrization)) diff --git a/tests/model_tests/diffusion/config_types.py b/tests/model_tests/diffusion/config_types.py new file mode 100644 index 000000000..4120900fe --- /dev/null +++ b/tests/model_tests/diffusion/config_types.py @@ -0,0 +1,187 @@ +""" +Common definitions for controlling what tests run where. +""" + +from collections.abc import Callable +from enum import StrEnum, auto +from typing import NamedTuple, TypeAlias + +from pytest import MarkDecorator + +from vllm_omni.diffusion.data import DiffusionParallelConfig +from vllm_omni.entrypoints.omni import Omni + +# All builder funcs take no params and return a path +TinyDiffusionBuilder: TypeAlias = Callable[[], str] + + +class DiffusionAccs(StrEnum): + """Supported acceleration types / test settings for Diffusion Models.""" + + HSDP = auto() + TEA_CACHE = auto() + CACHE_DIT = auto() + SEQUENCE_PARALLEL = auto() + CFG_PARALLEL = auto() + TENSOR_PARALLEL = auto() + CPU_OFFLOAD = auto() + LAYERWISE_OFFLOAD = auto() + VAE_PATCH_PARALLEL = auto() + + +class DiffusionTasks(StrEnum): + """Supported tasks for Diffusion Models.""" + + TEXT_TO_IMAGE = auto() + IMAGE_TO_IMAGE = auto() + # Text to video, text to audio, etc should be added here as needed + + +class DiffusionModelTestOpts(NamedTuple): + """Configuration for one Diffusion model's tests.""" + + # HF model name for real-weight tests (advanced_model / full_model level). + # For now, whether we use the real weights vs tiny weights in the common tests + # depends on the run level. + model: str + + # Creates a tiny model for the given architecture. We should always use tiny + # model weights for tests that do not require us to check the model quality. + builder: TinyDiffusionBuilder + + # Actual tasks which controls the tests actually run + supported_tasks: list[DiffusionTasks] + + # Additional acceleration groups to run beyond the base case (no acceleration). + # The base case is always run for every model in the test settings. None means + # we only run the base case. + extra_test_groups: list[list[DiffusionAccs]] | None = None + + # Pytest Marks for this model. This may be useful for selecting which models + # we want to run where, similar to the way vLLM's multimodal tests mark some + # as core models to always run in the CI. + # Example: https://github.com/vllm-project/vllm/blob/v0.23.0/tests/models/multimodal/generation/test_common.py#L131 + marks: list[MarkDecorator] | None = None + + # When True (default), online tests only run the base case (no accelerations). + # When False, online tests run all test_groups, same as offline. This should be + # True unless there is a good reason for it not to be, because the execution of + # the acceleration should be the same on both codepaths, and CLI parsing etc should + # be tested by tests adding the acceleration, and not per model. + online_base_only: bool = True + + # Additional checks to run for the base case. + check_multi_output: bool = True # Runs multiple generations in one request + check_determinism: bool = True # Runs 2 generations with the same seed and check determinism + + +### Mappings & utils for building offline Omni() instances given a list of enabled accelerations +ACC_OMNI_KWARGS = { + DiffusionAccs.VAE_PATCH_PARALLEL: {"vae_use_tiling": True}, + DiffusionAccs.CPU_OFFLOAD: {"enable_cpu_offload": True}, + DiffusionAccs.LAYERWISE_OFFLOAD: {"enable_layerwise_offload": True}, + DiffusionAccs.CACHE_DIT: {"cache_backend": "cache_dit"}, + DiffusionAccs.TEA_CACHE: {"cache_backend": "tea_cache"}, +} + +ACC_PARALLEL_KWARGS = { + DiffusionAccs.HSDP: {"use_hsdp": True, "hsdp_shard_size": 2}, + DiffusionAccs.TENSOR_PARALLEL: {"tensor_parallel_size": 2}, + DiffusionAccs.CFG_PARALLEL: {"cfg_parallel_size": 2}, + DiffusionAccs.VAE_PATCH_PARALLEL: {"vae_patch_parallel_size": 2}, + # For SP, we don't run ring here to conserve devices, since it is easy + # to blow up the number of needed devices fast. Compatibility for ring + # and ulysses together should be tested generically outside of these tests. + DiffusionAccs.SEQUENCE_PARALLEL: {"ulysses_degree": 2}, +} + +ACC_DEVICE_COUNT_KEYS = { + DiffusionAccs.TENSOR_PARALLEL: "tensor_parallel_size", + DiffusionAccs.CFG_PARALLEL: "cfg_parallel_size", + DiffusionAccs.SEQUENCE_PARALLEL: "ulysses_degree", + DiffusionAccs.VAE_PATCH_PARALLEL: "vae_patch_parallel_size", + DiffusionAccs.HSDP: "hsdp_shard_size", +} + +### CLI args for launching an OmniServer subprocess per acceleration. +# Follows the same pattern as e2e online serving tests, which hand-code +# their server_args lists in OmniServerParams. +ACC_SERVER_ARGS: dict[DiffusionAccs, list[str]] = { + DiffusionAccs.HSDP: ["--use-hsdp", "--hsdp-shard-size", "2"], + DiffusionAccs.TEA_CACHE: ["--cache-backend", "tea_cache"], + DiffusionAccs.CACHE_DIT: ["--cache-backend", "cache_dit"], + DiffusionAccs.SEQUENCE_PARALLEL: ["--usp", "2"], + DiffusionAccs.CFG_PARALLEL: ["--cfg-parallel-size", "2"], + DiffusionAccs.TENSOR_PARALLEL: ["--tensor-parallel-size", "2"], + DiffusionAccs.CPU_OFFLOAD: ["--enable-cpu-offload"], + DiffusionAccs.LAYERWISE_OFFLOAD: ["--enable-layerwise-offload"], + DiffusionAccs.VAE_PATCH_PARALLEL: ["--vae-use-tiling", "--vae-patch-parallel-size", "2"], +} + +## TODO ^ These should be in the same object, it's getting messy. + + +def get_required_device_count(accelerations: list[DiffusionAccs] | None) -> int: + """Compute the minimum number of devices needed for a set of accelerations. + The total is the product of all parallel dimensions (defaulting to 1). + + If not enough devices are available for a test group's accelerations, + that test will be skipped.""" + count = 1 + if accelerations is None: + return count + + for acc in accelerations: + key = ACC_DEVICE_COUNT_KEYS.get(acc) + if key is not None: + count *= ACC_PARALLEL_KWARGS[acc][key] + return count + + +def build_parallel_config_from_diff_accelerations(accelerations: list[DiffusionAccs]) -> DiffusionParallelConfig | None: + """Given a list of accelerations pertaining to the current test group, + build the parallel config needed for the Omni() object (if any).""" + config_kwargs = {} + for acc in accelerations: + update_dict = ACC_PARALLEL_KWARGS.get(acc, {}) + config_kwargs.update(update_dict) + if config_kwargs: + return DiffusionParallelConfig(**config_kwargs) + return None + + +### Offline Omni() object builder +def build_omni_from_diff_accelerations(accelerations: list[DiffusionAccs] | None, **kwargs) -> Omni: + """Given one or more acceleration types, build the corresponding Omni() object.""" + # Coerce to a list and build the parallel config, since that depends on the accelerations + if accelerations is None: + accelerations = [] + parallel_config = build_parallel_config_from_diff_accelerations(accelerations) + + # Then add anything else that's a top-level kwarg + acc_kwargs = {} + if parallel_config is not None: + acc_kwargs["parallel_config"] = parallel_config + for acc in accelerations: + update_dict = ACC_OMNI_KWARGS.get(acc, {}) + acc_kwargs.update(update_dict) + + # Keys passed through should mostly be things like enforce_eager; + # if there's overlap, it's probably due to a misconfiguration + shared_keys = acc_kwargs.keys() & kwargs.keys() + if shared_keys: + raise ValueError(f"Explicit Omni kwargs and inferred Omni kwargs for accelerations overlap: {shared_keys}") + omni_kwargs = {**acc_kwargs, **kwargs} + return Omni(**omni_kwargs) + + +### Online server flag builder +def build_server_args_from_diff_accelerations(accelerations: list[DiffusionAccs] | None) -> list[str]: + """Given one or more acceleration types, build the corresponding CLI args + for launching an OmniServer subprocess.""" + if accelerations is None: + return [] + args = [] + for acc in accelerations: + args.extend(ACC_SERVER_ARGS.get(acc, [])) + return args diff --git a/tests/model_tests/diffusion/diff_model_builders.py b/tests/model_tests/diffusion/diff_model_builders.py new file mode 100644 index 000000000..9bee71f9b --- /dev/null +++ b/tests/model_tests/diffusion/diff_model_builders.py @@ -0,0 +1,63 @@ +import os +import tempfile + +import torch +from diffusers.models.autoencoders.autoencoder_kl_flux2 import AutoencoderKLFlux2 +from diffusers.models.transformers.transformer_flux2 import Flux2Transformer2DModel +from diffusers.pipelines.flux2.pipeline_flux2_klein import Flux2KleinPipeline +from diffusers.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler +from transformers import AutoTokenizer, Qwen3Config, Qwen3ForCausalLM + +TINY_MODEL_DIR = os.path.join(tempfile.gettempdir(), "vllm-omni-tiny-models") + + +def _get_tiny_model_path(name: str) -> str: + path = os.path.join(TINY_MODEL_DIR, name) + os.makedirs(path, exist_ok=True) + return path + + +def tiny_flux2_klein_builder() -> str: + """Build a tiny Flux2Klein model.""" + model_dir = _get_tiny_model_path("Flux2KleinPipeline") + + pipe = Flux2KleinPipeline( + scheduler=FlowMatchEulerDiscreteScheduler(), + vae=AutoencoderKLFlux2( + down_block_types=("DownEncoderBlock2D",), + up_block_types=("UpDecoderBlock2D",), + block_out_channels=(32,), + layers_per_block=1, + latent_channels=16, + norm_num_groups=16, + ), + # NOTE: For now we need 28 layers because of hardcoded stuff in the model :( + text_encoder=Qwen3ForCausalLM( + Qwen3Config( + hidden_size=32, + intermediate_size=64, + num_hidden_layers=28, + num_attention_heads=2, + num_key_value_heads=2, + head_dim=16, + vocab_size=151936, + max_position_embeddings=512, + ) + ), + tokenizer=AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B"), + # NOTE: For now we need at least 2 layers for the transformer + # due to hardcoded hacks in CacheDiT for Flux2Klein specifically. + transformer=Flux2Transformer2DModel( + in_channels=64, + num_layers=2, + num_single_layers=2, + attention_head_dim=16, + num_attention_heads=2, + joint_attention_dim=96, + timestep_guidance_channels=32, + axes_dims_rope=(4, 4, 4, 4), + ), + ) + # Need dtypes to be consistent; for now we just put it on bfloat16 + pipe.to(torch.bfloat16).save_pretrained(model_dir) + return model_dir diff --git a/tests/model_tests/diffusion/model_settings.py b/tests/model_tests/diffusion/model_settings.py new file mode 100644 index 000000000..3f44afcae --- /dev/null +++ b/tests/model_tests/diffusion/model_settings.py @@ -0,0 +1,42 @@ +from tests.model_tests.diffusion import diff_model_builders +from tests.model_tests.diffusion.config_types import ( + DiffusionAccs, + DiffusionModelTestOpts, + DiffusionTasks, +) + +# This object defines the (tiny) model configurations for common tests. +# +# TO ADD A NEW MODEL: +# Map the pipeline name (i.e., the name of the pipeline in _DIFFUSION_MODELS) +# to a DiffusionModelTestOpts and define the supported tasks. A base case with no +# accelerations is always run. Use extra_test_groups to define additional +# acceleration configurations to run a basic smoke test for (i.e., simple inference). +# +# The model will only be loaded once per test group, and will execute each of its +# supported tasks as a pytest subtest. The base case also runs additional checks +# that are independent of accelerations, e.g., for determinism and compatibility with +# multi-output, unless explicitly disabled in the test settings. +# +# TO RUN A SUBSET OF THE TESTS: +# These tests should be fast, but if you'd like to run a subset of the tests, you can do so +# with `-k`; the IDs of the tests are the name of the pipeline joined by +. +# +# Example: +# $ pytest test_common_offline.py -k test_pipeline_on_supported_tasks[Flux2KleinPipeline] +# ^ Runs only the base (no accelerations) case for Flux2KleinPipeline +# +# $ pytest test_common_offline.py -k test_pipeline_on_supported_tasks[Flux2KleinPipeline +# ^ Runs all test groups for Flux2KleinPipeline only +DIFFUSION_TEST_SETTINGS = { + "Flux2KleinPipeline": DiffusionModelTestOpts( + model="black-forest-labs/FLUX.2-klein-4B", + builder=diff_model_builders.tiny_flux2_klein_builder, + supported_tasks=[DiffusionTasks.TEXT_TO_IMAGE, DiffusionTasks.IMAGE_TO_IMAGE], + extra_test_groups=[ + [DiffusionAccs.HSDP, DiffusionAccs.TEA_CACHE], + [DiffusionAccs.SEQUENCE_PARALLEL, DiffusionAccs.CACHE_DIT, DiffusionAccs.LAYERWISE_OFFLOAD], + [DiffusionAccs.CFG_PARALLEL, DiffusionAccs.TENSOR_PARALLEL, DiffusionAccs.CPU_OFFLOAD], + ], + ) +} diff --git a/tests/model_tests/diffusion/task_runners.py b/tests/model_tests/diffusion/task_runners.py new file mode 100644 index 000000000..6b9c7ff99 --- /dev/null +++ b/tests/model_tests/diffusion/task_runners.py @@ -0,0 +1,172 @@ +""" +Definitions running and validating individual tasks, e.g., text to image, +image to image, and so on. These are called by the core test runner. +""" + +import base64 +import io +from dataclasses import replace + +import numpy as np +from PIL import Image + +from tests.helpers.runtime import DiffusionResponse, OmniServer, OpenAIClientHandler, dummy_messages_from_mix_data +from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.outputs import OmniRequestOutput + +PROMPT = "Dummy prompt" +IMAGE_DIMS = (512, 512) +HEIGHT, WIDTH = IMAGE_DIMS +INPUT_IMAGE = Image.new("RGB", IMAGE_DIMS) + +# Offline sampling params +IMAGE_GEN_SAMPLING_PARAMS = OmniDiffusionSamplingParams( + num_inference_steps=4, + height=HEIGHT, + width=WIDTH, + seed=42, +) + +# Online extra_body for diffusion requests +IMAGE_GEN_EXTRA_BODY = { + "height": HEIGHT, + "width": WIDTH, + "num_inference_steps": 4, + "seed": 42, +} + + +### Shared validation +def _validate_images(images: list[Image.Image], expected_n: int = 1): + """Given a set of images, ensure we got the expected count of images, + and that all provided images match the expected dimensions.""" + assert len(images) == expected_n + for img in images: + assert isinstance(img, Image.Image) + assert img.size == IMAGE_DIMS + return images + + +def _validate_image_gen_determinism(images_a: list[Image.Image], images_b: list[Image.Image]): + """Ensure that two image sets are valid and that the results match.""" + _validate_images(images_a) + _validate_images(images_b) + assert np.array_equal(np.array(images_a[0]), np.array(images_b[0])) + + +### Output extractor utils for offline / online paths respectively +def _get_offline_images(outputs: list[OmniRequestOutput]) -> list[Image.Image]: + """Extract the images from an Omni .generate() call.""" + assert len(outputs) == 1 + return outputs[0].images + + +def _get_online_images(responses: list[DiffusionResponse]) -> list[Image.Image]: + """Extract the images from a server response.""" + assert len(responses) == 1 + images = responses[0].images + assert images is not None + return images + + +### Offline helpers +def _run_offline_t2i(omni: Omni, params: OmniDiffusionSamplingParams = IMAGE_GEN_SAMPLING_PARAMS): + return omni.generate({"prompt": PROMPT}, params) + + +def _run_offline_i2i(omni: Omni): + return omni.generate( + {"prompt": PROMPT, "multi_modal_data": {"image": INPUT_IMAGE}}, + IMAGE_GEN_SAMPLING_PARAMS, + ) + + +### Online helpers +def _build_online_image_data_url() -> str: + """Get a valid base 64 encoded data URL corresponding to an image.""" + buf = io.BytesIO() + INPUT_IMAGE.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/png;base64,{b64}" + + +def _run_online_t2i( + server: OmniServer, client: OpenAIClientHandler, extra_body: dict | None = None +) -> list[DiffusionResponse]: + """Run a text to image request through the server.""" + messages = dummy_messages_from_mix_data(content_text=PROMPT) + request_config = { + "model": server.model, + "messages": messages, + "extra_body": extra_body or IMAGE_GEN_EXTRA_BODY, + } + return client.send_diffusion_request(request_config) + + +def _run_online_i2i(server: OmniServer, client: OpenAIClientHandler) -> list[DiffusionResponse]: + """Run an image to image request through the server.""" + image_data_url = _build_online_image_data_url() + messages = dummy_messages_from_mix_data( + content_text=PROMPT, + image_data_url=image_data_url, + ) + request_config = { + "model": server.model, + "messages": messages, + "extra_body": IMAGE_GEN_EXTRA_BODY, + } + return client.send_diffusion_request(request_config) + + +### Offline task runners +def run_and_validate_text_to_image_request(omni: Omni): + """Run and validate a text to image request.""" + _validate_images(_get_offline_images(_run_offline_t2i(omni))) + + +def run_and_validate_image_to_image_request(omni: Omni): + """Run and validate an image to image request.""" + _validate_images(_get_offline_images(_run_offline_i2i(omni))) + + +def run_and_validate_text_to_image_determinism(omni: Omni): + """Checks for determinism; for now we just keep this for TTI.""" + _validate_image_gen_determinism( + _get_offline_images(_run_offline_t2i(omni)), + _get_offline_images(_run_offline_t2i(omni)), + ) + + +def run_and_validate_text_to_image_multi_output(omni: Omni): + """Checks for multi-output; for now we just keep this for TTI.""" + params = replace(IMAGE_GEN_SAMPLING_PARAMS, num_outputs_per_prompt=2) + _validate_images(_get_offline_images(_run_offline_t2i(omni, params)), expected_n=2) + + +### Online task runners +def run_and_validate_online_text_to_image_request(server: OmniServer, client: OpenAIClientHandler): + """Run and validate a text to image request through the server.""" + _validate_images(_get_online_images(_run_online_t2i(server, client))) + + +def run_and_validate_online_image_to_image_request(server: OmniServer, client: OpenAIClientHandler): + """Run and validate an image to image request through the server.""" + _validate_images(_get_online_images(_run_online_i2i(server, client))) + + +def run_and_validate_online_text_to_image_determinism(server: OmniServer, client: OpenAIClientHandler): + """Checks for determinism through the server; for now we just keep this for TTI.""" + _validate_image_gen_determinism( + _get_online_images(_run_online_t2i(server, client)), + _get_online_images(_run_online_t2i(server, client)), + ) + + +def run_and_validate_online_text_to_image_multi_output(server: OmniServer, client: OpenAIClientHandler): + """Checks for multi-output through the server; for now we just keep this for TTI.""" + extra_body = {**IMAGE_GEN_EXTRA_BODY, "num_outputs_per_prompt": 2} + _validate_images( + _get_online_images(_run_online_t2i(server, client, extra_body=extra_body)), + expected_n=2, + ) diff --git a/tests/model_tests/diffusion/test_alignment.py b/tests/model_tests/diffusion/test_alignment.py new file mode 100644 index 000000000..beb553994 --- /dev/null +++ b/tests/model_tests/diffusion/test_alignment.py @@ -0,0 +1,98 @@ +""" +Tests for alignment between _DIFFUSION_MODELS and DIFFUSION_TEST_SETTINGS; if +tests in this file are failing, you are probably adding a new model, and need +to account for it in DIFFUSION_TEST_SETTINGS +""" + +import pytest + +from tests.model_tests.diffusion.model_settings import DIFFUSION_TEST_SETTINGS +from vllm_omni.diffusion.registry import _DIFFUSION_MODELS + +pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] + +# These diffusion models currently do not have any added (tiny) model tests. +# In general, pipelines should only be in the list below if they have not been +# migrated yet or there is a compelling reason that they should not be (which for +# now may just be CI pressure etc due to the volume of models and the cost of +# initializing the server). +# +# The tests here validate that all keys in the diffusion registry are accounted for; +# if you are adding a new model and see the tests below fail, please follow the pattern +# for adding a new tiny model builder and corresponding entry in DIFFUSION_TEST_SETTINGS. +EXCLUDED_MODELS = [ + "QwenImagePipeline", + "QwenImageEditPipeline", + "QwenImageEditPlusPipeline", + "QwenImageLayeredPipeline", + "GlmImagePipeline", + "ZImagePipeline", + "OvisImagePipeline", + "WanPipeline", + "WanVACEPipeline", + "LTX2Pipeline", + "LTX2ImageToVideoPipeline", + "LTX2TwoStagesPipeline", + "LTX2ImageToVideoTwoStagesPipeline", + "LTX2T2VDMD2Pipeline", + "LTX2I2VDMD2Pipeline", + "LTX23Pipeline", + "LTX23ImageToVideoPipeline", + "StableAudioPipeline", + "WanImageToVideoPipeline", + "WanS2VPipeline", + "WanT2VDMD2Pipeline", + "WanI2VDMD2Pipeline", + "LongCatImagePipeline", + "BagelPipeline", + "LancePipeline", + "MingImagePipeline", + "InternVLAA1Pipeline", + "LongCatImageEditPipeline", + "StableDiffusion3Pipeline", + "FluxKontextPipeline", + "HunyuanImage3ForCausalMM", + "ErnieImagePipeline", + "NextStep11Pipeline", + "FluxPipeline", + "FluxDMD2Pipeline", + "QwenImageDMD2Pipeline", + "OmniGen2Pipeline", + "HeliosPipeline", + "HeliosPyramidPipeline", + "Flux2Pipeline", + "DreamIDOmniPipeline", + "SenseNovaU1Pipeline", + "AudioXPipeline", + "HunyuanVideo15Pipeline", + "HunyuanVideo15ImageToVideoPipeline", + "MagiHumanPipeline", + "OmniVoicePipeline", + "OmniVoice", + "Cosmos3OmniDiffusersPipeline", + "DiffusersAdapterPipeline", + "HiDreamImagePipeline", + "DreamZeroPipeline", + "StableDiffusionXLPipeline", + "Gr00tN1d7Pipeline", + "SoulXSingerPipeline", + "SoulXSingerSVCPipeline", +] + + +def test_all_non_excluded_pipelines_have_tests(): + """Ensure that every pipeline in the diffusion registry either has a test + configuration in DIFFUSION_TEST_SETTINGS, or is explicitly excluded for a + known reason.""" + non_excluded_diff_pipes = _DIFFUSION_MODELS.keys() - set(EXCLUDED_MODELS) + missing_tests = non_excluded_diff_pipes - DIFFUSION_TEST_SETTINGS.keys() + assert len(missing_tests) == 0, f"Pipelines missing test settings: {missing_tests}" + + +def test_no_excluded_models_have_test_settings(): + """Ensure that no models in the exclude list have a test configuration in + DIFFUSION_TEST_SETTINGS (i.e., if they have test settings, we should not + exclude them).""" + non_excluded_diff_pipes = _DIFFUSION_MODELS.keys() - set(EXCLUDED_MODELS) + missing_pipes = DIFFUSION_TEST_SETTINGS.keys() - set(non_excluded_diff_pipes) + assert len(missing_pipes) == 0 diff --git a/tests/model_tests/diffusion/test_common_offline.py b/tests/model_tests/diffusion/test_common_offline.py new file mode 100644 index 000000000..f24ed3780 --- /dev/null +++ b/tests/model_tests/diffusion/test_common_offline.py @@ -0,0 +1,69 @@ +import pytest + +from tests.model_tests.diffusion.case_filtering import get_parametrized_options +from tests.model_tests.diffusion.config_types import ( + DiffusionAccs, + DiffusionTasks, + build_omni_from_diff_accelerations, +) +from tests.model_tests.diffusion.model_settings import DIFFUSION_TEST_SETTINGS +from tests.model_tests.diffusion.task_runners import ( + run_and_validate_image_to_image_request, + run_and_validate_text_to_image_determinism, + run_and_validate_text_to_image_multi_output, + run_and_validate_text_to_image_request, +) + +# NOTE : Hardware marks are added dynamically based on test requirements +pytestmark = [pytest.mark.diffusion] + + +@pytest.mark.parametrize( + "model_name,accelerations,supported_tasks,check_multioutput,check_determinism", + get_parametrized_options(DIFFUSION_TEST_SETTINGS), +) +def test_pipeline_on_supported_tasks( + model_name, + accelerations: list[DiffusionAccs] | None, + supported_tasks: list[DiffusionTasks], + check_multioutput: bool, + check_determinism: bool, + tiny_model_paths: dict[str, str], + subtests, +): + """Run a smoke test on all of the pipelines supported tasks using a set of enabled accelerations.""" + assert len(supported_tasks) > 0 + # We initialize the Omni object before running the tasks, then run each task as a pytest subtask. + # This lets us init the model once, but display separate failures in pytest, and avoid halting the + # checks on other tasks if one fails. + # + # This allows us to have some degree of test isolation without the cost of redundant initialization, + # since starting the server can take 10+ seconds, even for tiny models. + # + # NOTE: Be sure to install pytest-subtests if you're running on pytest < 9 + omni = build_omni_from_diff_accelerations( + accelerations=accelerations, + model=tiny_model_paths[model_name], + enforce_eager=True, + ) + try: + for task_type in supported_tasks: + with subtests.test(msg=task_type): + if task_type == DiffusionTasks.TEXT_TO_IMAGE: + run_and_validate_text_to_image_request(omni) + elif task_type == DiffusionTasks.IMAGE_TO_IMAGE: + run_and_validate_image_to_image_request(omni) + else: + raise ValueError(f"Task type {task_type} is not yet supported") + + # NOTE: For now, we only check determinism + multi output for the base case, + # since checking it on every extra acceleration configuration is redundant + # (see case_filtering). + if check_determinism: + with subtests.test(msg="determinism"): + run_and_validate_text_to_image_determinism(omni) + if check_multioutput: + with subtests.test(msg="multi_output"): + run_and_validate_text_to_image_multi_output(omni) + finally: + omni.close() diff --git a/tests/model_tests/diffusion/test_common_online.py b/tests/model_tests/diffusion/test_common_online.py new file mode 100644 index 000000000..e4c6be0d4 --- /dev/null +++ b/tests/model_tests/diffusion/test_common_online.py @@ -0,0 +1,75 @@ +""" +Analogous to test_common_offline, but for server tests. Validates the full +online serving stack (CLI arg parsing, subprocess, API routing, response +encoding) using tiny models. +""" + +import pytest + +from tests.helpers.runtime import OmniServer, OpenAIClientHandler +from tests.model_tests.diffusion.case_filtering import get_parametrized_options +from tests.model_tests.diffusion.config_types import ( + DiffusionAccs, + DiffusionTasks, + build_server_args_from_diff_accelerations, +) +from tests.model_tests.diffusion.model_settings import DIFFUSION_TEST_SETTINGS +from tests.model_tests.diffusion.task_runners import ( + run_and_validate_online_image_to_image_request, + run_and_validate_online_text_to_image_determinism, + run_and_validate_online_text_to_image_multi_output, + run_and_validate_online_text_to_image_request, +) + +# NOTE : Hardware marks are added dynamically based on test requirements +pytestmark = [pytest.mark.diffusion] + + +@pytest.mark.parametrize( + "model_name,accelerations,supported_tasks,check_multioutput,check_determinism", + get_parametrized_options(DIFFUSION_TEST_SETTINGS, online=True), +) +def test_online_on_supported_tasks( + model_name: str, + accelerations: list[DiffusionAccs] | None, + supported_tasks: list[DiffusionTasks], + check_multioutput: bool, + check_determinism: bool, + tiny_model_paths: dict[str, str], + run_level: str, + subtests, +): + """Smoke test: start a tiny model server and run each supported task via the API.""" + model_path = tiny_model_paths[model_name] + server_args = build_server_args_from_diff_accelerations(accelerations) + server_args.append("--enforce-eager") + + with OmniServer(model_path, server_args) as server: + # TODO: We may want to revisit run_level validation here, + # because checks for things like image size etc should not + # depend on whether or not the weights are real or random + client = OpenAIClientHandler( + host=server.host, + port=server.port, + api_key="EMPTY", + run_level=run_level, + log_stats=server.log_stats, + ) + for task_type in supported_tasks: + with subtests.test(msg=task_type): + if task_type == DiffusionTasks.TEXT_TO_IMAGE: + run_and_validate_online_text_to_image_request(server, client) + elif task_type == DiffusionTasks.IMAGE_TO_IMAGE: + run_and_validate_online_image_to_image_request(server, client) + else: + raise ValueError(f"Task type {task_type} is not yet supported") + + # NOTE: For now, we only check determinism + multi output for the base case, + # since checking it on every extra acceleration configuration is redundant + # (see case_filtering). + if check_determinism: + with subtests.test(msg="determinism"): + run_and_validate_online_text_to_image_determinism(server, client) + if check_multioutput: + with subtests.test(msg="multi_output"): + run_and_validate_online_text_to_image_multi_output(server, client) diff --git a/tests/model_tests/diffusion/utils.py b/tests/model_tests/diffusion/utils.py new file mode 100644 index 000000000..35577b68f --- /dev/null +++ b/tests/model_tests/diffusion/utils.py @@ -0,0 +1,33 @@ +""" +Utilities for resolving real models to their tiny model equivalents. +""" + +import logging + +from tests.model_tests.diffusion.model_settings import DIFFUSION_TEST_SETTINGS +from vllm_omni.diffusion.data import resolve_model_class_name + +logger = logging.getLogger(__name__) + + +def resolve_tiny_model_path(model: str) -> str: + """Given a real model name/path, resolve it to a tiny model path. + + Raises ValueError if the pipeline class cannot be determined (invalid + model). Returns the original model path if no tiny builder exists yet.""" + pipeline_class = resolve_model_class_name(model) + if pipeline_class is None: + raise ValueError( + f"Cannot resolve pipeline class for model: {model}. The model path may be invalid or its config unreadable." + ) + + test_opts = DIFFUSION_TEST_SETTINGS.get(pipeline_class) + if test_opts is None: + logger.warning( + "No tiny model builder for pipeline %s (model: %s). Using original model.", + pipeline_class, + model, + ) + return model + + return test_opts.builder() From ca0ae7269ca3e9487645cf66088fdfc338951da9 Mon Sep 17 00:00:00 2001 From: Ricardo Noriega Date: Thu, 9 Jul 2026 17:09:23 +0200 Subject: [PATCH 053/101] Bump safetensors to the 0.8.0 release (#4713) Signed-off-by: Ricardo Noriega De Soto --- requirements/common.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/common.txt b/requirements/common.txt index bfc7093f6..243a6ce7e 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -13,7 +13,7 @@ transformers >= 5.5.3 av>=14.0.0 omegaconf>=2.3.0 diffusers==0.38.0 -safetensors>=0.8.0rc0 +safetensors>=0.8.0 accelerate==1.12.0 soundfile>=0.13.1 cache-dit==1.3.0 From 5dc787d5e8d10301a47fde006200f641741f11b1 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Fri, 10 Jul 2026 11:11:29 +0800 Subject: [PATCH 054/101] [Perf][Qwen3-TTS] Align talker MTP CUDA graph capture with Qwen3-Omni & Adapt to async output (#4923) Signed-off-by: amy-why-3459 --- vllm_omni/deploy/qwen3_tts.yaml | 13 +++++--- .../qwen3_tts_code_predictor_vllm.py | 5 ++-- .../models/qwen3_tts/qwen3_tts_talker.py | 30 +++++++++++++++---- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/vllm_omni/deploy/qwen3_tts.yaml b/vllm_omni/deploy/qwen3_tts.yaml index 4f95c1f48..f27da1b5b 100644 --- a/vllm_omni/deploy/qwen3_tts.yaml +++ b/vllm_omni/deploy/qwen3_tts.yaml @@ -35,7 +35,7 @@ connectors: stages: - stage_id: 0 - max_num_seqs: 10 + max_num_seqs: 64 gpu_memory_utilization: 0.3 trust_remote_code: true # Prefix caching on by default. The HS opt-out (talker only consumes the @@ -43,9 +43,9 @@ stages: # PR make per-step bookkeeping negligible, and any text repetition across # requests (same voice or shared ref_audio) is a net throughput win. # Disable explicitly if memory headroom is tight on a single-GPU deploy. - enable_prefix_caching: true + enable_prefix_caching: false async_scheduling: true - max_num_batched_tokens: 512 + max_num_batched_tokens: 32768 max_model_len: 4096 devices: "0" output_connectors: @@ -67,7 +67,7 @@ stages: - stage_id: 1 # Tuned for Base voice clone; CustomVoice / VoiceDesign are TTFA-optimal at 1. - max_num_seqs: 10 + max_num_seqs: 64 gpu_memory_utilization: 0.3 enforce_eager: false trust_remote_code: true @@ -95,3 +95,8 @@ platforms: stages: - stage_id: 0 enforce_eager: false + compilation_config: + cudagraph_mode: PIECEWISE + - stage_id: 1 + compilation_config: + cudagraph_mode: PIECEWISE diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py index 8d2f0686a..9d00cc2ba 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code_predictor_vllm.py @@ -13,6 +13,7 @@ from vllm_omni.model_executor.models.common.qwen3_code_predictor import ( CodePredictorWrapper, CodePredictorWrapperConfig, ) +from vllm_omni.platforms import current_omni_platform from .configuration_qwen3_tts import Qwen3TTSTalkerCodePredictorConfig, Qwen3TTSTalkerConfig @@ -21,7 +22,7 @@ Qwen3TTSTalkerCodePredictorModelVLLM = CodePredictorBaseModel class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(CodePredictorWrapper): - """Qwen3-TTS code predictor (CUDA graphs, per-call sampling, projection).""" + """Qwen3-TTS code predictor (per-call sampling, projection).""" def __init__( self, @@ -35,7 +36,7 @@ class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(CodePredictorWrapp vllm_config=vllm_config, cp_config=config, wrapper_config=CodePredictorWrapperConfig( - use_cuda_graphs=True, + use_cuda_graphs=current_omni_platform.is_npu(), use_parallel_embedding=False, use_projection=(config.hidden_size != talker_config.hidden_size), return_proj_buf=False, 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 2d28a0730..3daeb4cf2 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 @@ -324,13 +324,33 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): # OmniGPUModelRunner will store talker_mtp output under this key in # per-request additional_information. self.talker_mtp_output_key = ("codes", "audio") + self.talker_mtp_graph_safe = True # talker_mtp samples with per-row generators, so explicitly-seeded # requests stay batched instead of one scalar forward per row (#4883). - # Only valid while talker_mtp receives the unpadded active batch (this - # talker is not graph-wrapped); a padded batch would need the runner to - # pad the generators list as well. - self.talker_mtp_accepts_per_row_generators = True - + # This is only valid while talker_mtp receives the *unpadded* active + # batch, i.e. when it is NOT graph-wrapped. The runner wraps talker_mtp + # (padded batches + a single captured RNG stream) whenever full + # cudagraphs are enabled, so disable per-row generators in that case. + # Consequence: under full cudagraphs, per-request ``tts_local_seed`` is + # not reproducible (matching Qwen3-Omni, which has no per-row seeding); + # eager mode keeps the per-row generator fast-path. + # TODO(#4923): the model should not read ``cudagraph_mode`` — it is an + # upstream (runner) concern, and the runner already re-derives the same + # wrap decision in ``_init_talker_mtp``. Once all TTS models declare + # ``talker_mtp_graph_safe`` we should move this gating into the model + # runner (models only expose the static capability flag, and users can + # then configure ``cudagraph_mode`` freely). This also means mtp seeding + # is silently dropped when the decode batch > 1 under full cudagraphs; + # the follow-up should refactor the per-row generator in the runner to + # be cudagraph-safe so seeded batches stay reproducible. + cudagraph_mode = getattr(vllm_config.compilation_config, "cudagraph_mode", None) + talker_mtp_graph_wrapped = ( + self.talker_mtp_graph_safe and cudagraph_mode is not None and cudagraph_mode.has_full_cudagraphs() + ) + self.talker_mtp_accepts_per_row_generators = not talker_mtp_graph_wrapped + self.use_async_omni_output = True + self.eager_omni_postprocess_before_async_output = True + self.omni_pooler_payload_include_hidden = False self.model = Qwen3Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) if get_pp_group().is_last_rank: From b3a481969410b57f815f53213d4a0fc7571f4e4e Mon Sep 17 00:00:00 2001 From: leo <92263211+napleon-liu@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:01:10 +0800 Subject: [PATCH 055/101] Fix VoxCPM2 perf server trust remote code (#4984) Signed-off-by: leo --- tests/dfx/perf/tests/test_voxcpm2.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/dfx/perf/tests/test_voxcpm2.json b/tests/dfx/perf/tests/test_voxcpm2.json index beb40d5d3..5c99f2596 100644 --- a/tests/dfx/perf/tests/test_voxcpm2.json +++ b/tests/dfx/perf/tests/test_voxcpm2.json @@ -3,7 +3,10 @@ "test_name": "test_voxcpm2", "server_params": { "model": "openbmb/VoxCPM2", - "trust_remote_code": true + "trust_remote_code": true, + "extra_cli_args": [ + "--trust-remote-code" + ] }, "benchmark_params": [ { From 020792e6be54cc2d06593bae900453f54cad759d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=91=AB?= <77673148+l-wave@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:41:54 +0800 Subject: [PATCH 056/101] [Perf][Qwen3-TTS] Sample code predictor outputs via Gumbel-max (#4261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 林鑫 <77673148+l-wave@users.noreply.github.com> Co-authored-by: yurain --- .../qwen3_tts/test_code_predictor_dtype.py | 92 +++++++++++++++++++ .../models/common/qwen3_code_predictor.py | 51 ++++++++-- 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py index b73c15fc7..519940ec3 100644 --- a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py +++ b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py @@ -18,8 +18,11 @@ import types import pytest import torch +import torch.nn.functional as F from pytest_mock import MockerFixture +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + # Direct file import to avoid vllm_omni.__init__ patch dependencies. _MODELS = os.path.join( os.path.dirname(__file__), @@ -542,3 +545,92 @@ class TestCodePredictorWrapperConfig: wrapper_config=common_mod.CodePredictorWrapperConfig(use_cuda_graphs=True), ) assert graph_wrapper._prefix_graphs_enabled is True + + +class TestGumbelMaxSampling: + """Sanity tests for the shared Gumbel-max sampling helper.""" + + @staticmethod + def _sample(logits: torch.Tensor, generator=None) -> torch.Tensor: + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + return common_mod.CodePredictorWrapper._sample_codes_gumbel(logits, generator=generator) + + def test_gumbel_max_picks_unique_high_logit(self, loaded_target_classes) -> None: + """If one logit is dominant, Gumbel-max must always pick it.""" + torch.manual_seed(0) + bsz, vocab = 4, 64 + logits = torch.full((bsz, vocab), -1e4) + logits[:, 7] = 0.0 # only finite entry + out = self._sample(logits) + assert out.shape == (bsz, 1) + assert torch.equal(out.squeeze(-1), torch.full((bsz,), 7, dtype=torch.long)) + + def test_gumbel_max_handles_single_finite_entry_rows(self, loaded_target_classes) -> None: + """Degenerate rows with one surviving finite entry should still return + a valid index instead of tripping multinomial-style asserts.""" + bsz, vocab = 2, 8 + logits = torch.full((bsz, vocab), float("-inf")) + logits[0, 3] = 0.0 + logits[1, 5] = 0.0 + out = self._sample(logits) + assert out[0].item() == 3 + assert out[1].item() == 5 + + def test_gumbel_max_generator_makes_results_reproducible(self, loaded_target_classes) -> None: + """Same ``Generator.manual_seed`` should produce identical samples.""" + torch.manual_seed(0) + logits = torch.randn(8, 32) + g1 = torch.Generator() + g1.manual_seed(123) + g2 = torch.Generator() + g2.manual_seed(123) + g3 = torch.Generator() + g3.manual_seed(456) + s1 = self._sample(logits, generator=g1) + s2 = self._sample(logits, generator=g2) + s3 = self._sample(logits, generator=g3) + assert torch.equal(s1, s2) + # Different seeds must (almost surely) yield a different sequence. + assert not torch.equal(s1, s3) + + def test_gumbel_max_per_row_generators_match_individual_rows(self, loaded_target_classes) -> None: + """A per-row generator list should preserve row independence.""" + torch.manual_seed(11) + logits = torch.randn(3, 16) + seeds = [101, 202, 303] + batched_generators = [] + expected_rows = [] + for row, seed in enumerate(seeds): + row_generator = torch.Generator() + row_generator.manual_seed(seed) + batched_generators.append(row_generator) + + solo_generator = torch.Generator() + solo_generator.manual_seed(seed) + expected_rows.append(self._sample(logits[row : row + 1], generator=solo_generator)) + + batched = self._sample(logits, generator=batched_generators) + expected = torch.cat(expected_rows, dim=0) + assert torch.equal(batched, expected) + + def test_gumbel_max_rejects_generator_length_mismatch(self, loaded_target_classes) -> None: + logits = torch.randn(2, 8) + generators = [torch.Generator()] + with pytest.raises(ValueError, match="Expected 2 per-row generators"): + self._sample(logits, generator=generators) + + def test_gumbel_max_distribution_matches_softmax(self, loaded_target_classes) -> None: + """Empirical frequency under Gumbel-max should be close to the + softmax probability mass. Loose tolerance because we draw a + finite number of samples.""" + torch.manual_seed(7) + vocab = 16 + logits = torch.randn(vocab) * 2.0 + target = F.softmax(logits, dim=-1, dtype=torch.float32) + + n_draws = 20000 + batched_logits = logits.unsqueeze(0).expand(n_draws, vocab).contiguous() + samples = self._sample(batched_logits).squeeze(-1) + empirical = torch.bincount(samples, minlength=vocab).float() / n_draws + # L1 distance under 0.03 with 20k draws is comfortable for vocab=16. + assert (empirical - target).abs().sum().item() < 0.05 diff --git a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py index c8591c32e..a8a259504 100644 --- a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py +++ b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py @@ -28,6 +28,9 @@ from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) +_GeneratorLike = torch.Generator | Sequence[torch.Generator | None] | None +_UNIFORM_EPS = 1e-20 + # =================================================================== # HF-numerics-compatible layers for code predictor @@ -605,6 +608,30 @@ class CodePredictorWrapper(nn.Module): values.add(parsed) return values + @staticmethod + def _normalize_generators( + generator: _GeneratorLike, batch_size: int + ) -> torch.Generator | list[torch.Generator | None] | None: + if generator is None or isinstance(generator, torch.Generator): + return generator + + row_generators = list(generator) + if len(row_generators) != batch_size: + raise ValueError(f"Expected {batch_size} per-row generators, but got {len(row_generators)}.") + return row_generators + + @classmethod + def _sample_codes_gumbel(cls, logits: torch.Tensor, generator: _GeneratorLike = None) -> torch.Tensor: + """Sample ``logits`` via Gumbel-max with optional per-row generators.""" + row_generators = cls._normalize_generators(generator, int(logits.shape[0])) + u = torch.empty_like(logits, dtype=torch.float32) + if isinstance(row_generators, list): + for row, row_generator in enumerate(row_generators): + u[row : row + 1].uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generator) + else: + u.uniform_(_UNIFORM_EPS, 1.0 - _UNIFORM_EPS, generator=row_generators) + return (logits.float() - torch.log(-torch.log(u))).argmax(dim=-1, keepdim=True) + def _prefix_seq_lens(self, max_seq: int) -> list[int]: all_seq_lens = list(range(2, max_seq)) if not self._prefix_graph_seq_lens: @@ -780,6 +807,7 @@ class CodePredictorWrapper(nn.Module): bsz = int(layer0_code.shape[0]) if generators is not None and len(generators) != bsz: raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}") + sample_generator: _GeneratorLike = generators if generators is not None else generator num_groups = self._num_groups device = layer0_code.device @@ -856,9 +884,20 @@ class CodePredictorWrapper(nn.Module): logits = lm_heads[step - 1](hidden_out[:bsz, step, :]) - # Sample next code + # Sample next code via Gumbel-max. + # + # ``argmax_i(logits_i + Gumbel_i)`` with + # ``Gumbel_i = -log(-log(u_i)), u_i ~ Uniform(0, 1)`` is + # distributionally identical to sampling from ``softmax(logits)``. + # In this file the motivations are practical rather than graph + # related: it is measurably cheaper than ``softmax + multinomial`` + # on the B x 2048 shapes used here, it stays well-defined for + # degenerate masked rows with a surviving finite entry (and is more + # defensive than ``multinomial`` around fully-masked/NaN inputs), + # and the helper below can honor either one batch generator or one + # generator per seeded row. if stored_mode: - # "stored" mode: top-k -> top-p -> softmax -> multinomial + # "stored" mode: top-k -> top-p -> Gumbel-max if s_top_k > 0: topk_vals, _ = logits.topk(s_top_k, dim=-1) logits = logits.masked_fill(logits < topk_vals[:, -1:], float("-inf")) @@ -869,17 +908,15 @@ class CodePredictorWrapper(nn.Module): remove_mask = (cumulative_probs - sorted_probs) >= s_top_p sorted_logits[remove_mask] = float("-inf") logits = sorted_logits.scatter(1, sorted_idx, sorted_logits) - probs = F.softmax(logits, dim=-1, dtype=torch.float32) - code = self._multinomial(probs, generator, generators) + code = self._sample_codes_gumbel(logits, generator=sample_generator) else: - # "per_call" mode: temperature-scaled + top-k + # "per_call" mode: temperature-scaled + top-k -> Gumbel-max if use_sampling: scaled = logits * inv_temperature if top_k > 0: topk_vals, _ = scaled.topk(top_k, dim=-1) scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf")) - probs = F.softmax(scaled, dim=-1, dtype=torch.float32) - code = self._multinomial(probs, generator, generators) + code = self._sample_codes_gumbel(scaled, generator=sample_generator) else: code = logits.argmax(dim=-1, keepdim=True) From 9fe26cfacbc4d00cbcb5f1608bddefdc3c5ccc20 Mon Sep 17 00:00:00 2001 From: Zheng Wengang Date: Fri, 10 Jul 2026 14:24:18 +0800 Subject: [PATCH 057/101] [Bugfix][Model] Fix Qwen3-Omni video metadata handling for use_audio_in_video (#4959) Signed-off-by: ZhengWG --- .../test_qwen3_omni_expansion.py | 38 +++++ .../models/qwen2_5_omni/qwen2_5_omni.py | 2 +- .../qwen2_5_omni/qwen2_5_omni_thinker.py | 143 +++++++++++++++++- .../qwen3_omni/qwen3_omni_moe_thinker.py | 30 +++- 4 files changed, 206 insertions(+), 7 deletions(-) diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py index db3d7a8d1..25ba4f284 100644 --- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py +++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py @@ -410,6 +410,44 @@ def test_audio_in_video_002(omni_server, openai_client) -> None: openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) +@pytest.mark.parametrize("omni_server", test_params, indirect=True) +def test_audio_in_video_default_loader_sampling_regression(omni_server, openai_client) -> None: + """ + Regression: ``use_audio_in_video`` with default video loader sampling (no + ``media_io_kwargs.video.fps``) after a prior request that pinned fps. + + Before the fix the second request could partial-hit mm-cache (audio cached, + video re-processed without audio) and fail with ``StopIteration`` in + ``replace_multimodal_special_tokens``. + """ + video_data_url = f"data:video/mp4;base64,{generate_synthetic_video(224, 224, 128, embed_audio=True)['base64']}" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + video_data_url=video_data_url, + content_text="Reply with one word: OK.", + ) + + base_config = { + "model": omni_server.model, + "messages": messages, + "stream": False, + "use_audio_in_video": True, + "modalities": ["text"], + } + + # Prime mm-cache with explicit loader fps (historical failure mode). + openai_client.send_omni_request( + { + **base_config, + "extra_body": {"media_io_kwargs": {"video": {"fps": 1, "num_frames": 128}}}, + } + ) + + # Default loader sampling: must succeed without explicit media_io fps. + openai_client.send_omni_request(dict(base_config)) + + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) @pytest.mark.parametrize("omni_server", test_params, indirect=True) def test_one_word_prompt_001(omni_server, openai_client) -> None: diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni.py b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni.py index 4bd48c043..7b2c2558c 100644 --- a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni.py +++ b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni.py @@ -17,7 +17,6 @@ from vllm.logger import init_logger from vllm.model_executor.models.interfaces import SupportsMRoPE, SupportsMultiModal, SupportsPP from vllm.model_executor.models.qwen2_5_omni_thinker import ( Qwen2_5OmniConditionalGenerationMixin, - Qwen2_5OmniThinkerProcessingInfo, ) from vllm.model_executor.models.utils import init_vllm_registered_model, maybe_prefix from vllm.model_executor.models.vision import ( @@ -39,6 +38,7 @@ from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import ( Qwen2_5OmniThinkerDummyInputsBuilder, Qwen2_5OmniThinkerMultiModalProcessor, + Qwen2_5OmniThinkerProcessingInfo, ) from vllm_omni.model_executor.models.utils import add_prefix_to_loaded_weights, split_list_into_ranges from vllm_omni.platforms import current_omni_platform 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 5d58a63d4..d0df1c2ea 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 @@ -4,6 +4,7 @@ from collections.abc import Iterable, Iterator, Mapping from typing import Any import numpy as np +import PIL.Image as PILImage import torch from torch import nn from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import ( @@ -26,16 +27,21 @@ from vllm.model_executor.models.module_mapping import MultiModelKeys from vllm.model_executor.models.qwen2_5_omni_thinker import ( Qwen2_5OmniAudioFeatureInputs, Qwen2_5OmniThinkerDummyInputsBuilder, - Qwen2_5OmniThinkerProcessingInfo, check_interleaved_audio_video, merge_interleaved_embeddings, ) from vllm.model_executor.models.qwen2_5_omni_thinker import ( Qwen2_5OmniConditionalGenerationMixin as Qwen2_5OmniConditionalGenerationMixinBase, ) +from vllm.model_executor.models.qwen2_5_omni_thinker import ( + Qwen2_5OmniThinkerMultiModalDataParser as _Qwen2_5OmniThinkerMultiModalDataParserBase, +) from vllm.model_executor.models.qwen2_5_omni_thinker import ( Qwen2_5OmniThinkerMultiModalProcessor as _Qwen2_5OmniThinkerMultiModalProcessorBase, ) +from vllm.model_executor.models.qwen2_5_omni_thinker import ( + Qwen2_5OmniThinkerProcessingInfo as _Qwen2_5OmniThinkerProcessingInfoBase, +) from vllm.model_executor.models.qwen2_5_vl import ( Qwen2_5_VisionTransformer, Qwen2_5_VLImageEmbeddingInputs, @@ -56,12 +62,13 @@ from vllm.multimodal.inputs import ( MultiModalFeatureSpec, MultiModalKwargsItems, ) -from vllm.multimodal.parse import MultiModalDataItems +from vllm.multimodal.parse import MultiModalDataItems, VideoProcessorItems from vllm.multimodal.processing.processor import ( MultiModalPromptUpdates, PlaceholderFeaturesInfo, ) from vllm.sequence import IntermediateTensors +from vllm.utils.collection_utils import is_list_of from vllm_omni.quantization.component_config import ( resolve_encoder_quant_config, @@ -74,11 +81,143 @@ except (ImportError, ModuleNotFoundError): logger = init_logger(__name__) +def _presampled_videos_hf_kwargs( + mm_data: Mapping[str, object], + mm_kwargs: Mapping[str, object], +) -> Mapping[str, object]: + """Adjust HF video kwargs for videos pre-sampled by vLLM's video loader. + + When ``video_metadata`` is present (emitted by + ``Qwen2_5OmniVideoProcessorItems``), the frames were already sampled + according to ``media_io_kwargs``, so the HF processor must not re-sample + them, and it needs the sampled fps (instead of its default) to compute + ``video_second_per_grid`` correctly. Otherwise the audio/video temporal + alignment breaks under ``use_audio_in_video=True``. + """ + video_metadata = mm_data.get("video_metadata") + if not video_metadata: + return mm_kwargs + + mm_kwargs = dict(mm_kwargs) + videos_kwargs = dict(mm_kwargs.get("videos_kwargs") or {}) + videos_kwargs["do_sample_frames"] = False + + def _compute_sampled_video_fps(metadata) -> float | None: + duration = getattr(metadata, "duration", None) + indices = getattr(metadata, "frames_indices", None) + if not duration or not indices or duration <= 0: + return None + return len(indices) / float(duration) + + # An explicit user-provided fps takes precedence. + if "fps" not in videos_kwargs: + fps_values = [_compute_sampled_video_fps(m) for m in video_metadata] + # HF accepts a single fps per call and uses it for every video's + # video_second_per_grid. + known_fps = [fps for fps in fps_values if fps is not None] + unique_fps = set(known_fps) + if len(unique_fps) == 1: + videos_kwargs["fps"] = known_fps[0] + elif len(unique_fps) > 1: + logger.warning( + f"Mixed sampled FPS {sorted(unique_fps)} in one request; HF accepts a single fps, using {known_fps[0]}." + ) + videos_kwargs["fps"] = known_fps[0] + + mm_kwargs["videos_kwargs"] = videos_kwargs + return mm_kwargs + + +class Qwen2_5OmniVideoProcessorItems(VideoProcessorItems): + """Video items that carry the loader's ``(frames, metadata)`` tuples. + + The tuples are kept in ``data`` so that mm hashing and cache-miss + re-parsing retain the metadata; ``get_processor_data`` unpacks them into + the ``videos`` + ``video_metadata`` arguments of the HF processor. + """ + + def get_processor_data(self) -> Mapping[str, object]: + from transformers.video_utils import VideoMetadata + + videos = [frames for frames, _ in self.data] + video_metadata = [ + VideoMetadata(**{k: v for k, v in metadata.items() if k != "do_sample_frames"}) for _, metadata in self.data + ] + return {"videos": videos, "video_metadata": video_metadata} + + +class Qwen2_5OmniThinkerMultiModalDataParser(_Qwen2_5OmniThinkerMultiModalDataParserBase): + def _parse_video_data(self, data): + if data is None or isinstance(data, dict) or self.is_embeddings(data): + return super()._parse_video_data(data) + + # Normalize to a list of per-video items (same as the base parser). + if (is_list_of(data, PILImage.Image) and len(data) > 0) or ( + isinstance(data, (np.ndarray, torch.Tensor)) and data.ndim == 4 + ): + data_items = [data] + elif isinstance(data, (np.ndarray, torch.Tensor)): + data_items = [elem for elem in data] + elif isinstance(data, tuple) and len(data) == 2: + data_items = [data] + else: + data_items = data + + videos_with_metadata = [self._get_video_with_metadata(item) for item in data_items] + + # Metadata is optional: videos fetched by vLLM's media connector + # arrive as (frames, metadata) tuples, while plain arrays (e.g. + # offline `multi_modal_data` or dummy profiling inputs) carry no + # metadata and parse as before. + if any(metadata is None for _, metadata in videos_with_metadata): + return super()._parse_video_data(data) + + return Qwen2_5OmniVideoProcessorItems( + videos_with_metadata, + metadata=[metadata for _, metadata in videos_with_metadata], + ) + + +class Qwen2_5OmniThinkerProcessingInfo(_Qwen2_5OmniThinkerProcessingInfoBase): + def get_data_parser(self): + feature_extractor = self.get_feature_extractor() + + return Qwen2_5OmniThinkerMultiModalDataParser( + spatial_merge_size=self.get_hf_config().vision_config.spatial_merge_size, + target_sr=feature_extractor.sampling_rate, + target_channels=self.get_target_channels(), + expected_hidden_size=self._get_expected_hidden_size(), + ) + + class Qwen2_5OmniThinkerMultiModalProcessor( _Qwen2_5OmniThinkerMultiModalProcessorBase, ): """Override to fix use_audio_in_video detection when mm cache returns None.""" + def _cached_apply_hf_processor(self, inputs, timing_ctx): + # If use_audio_in_video, process video and audio together; otherwise, + # skip cache to avoid errors. + # Prevents partial cache hits from causing processing failures. + if inputs.hf_processor_mm_kwargs.get("use_audio_in_video"): + return self._apply_hf_processor(inputs, timing_ctx) + return super()._cached_apply_hf_processor(inputs, timing_ctx) + + def _call_hf_processor( + self, + prompt: str, + mm_data: Mapping[str, object], + mm_kwargs: Mapping[str, object], + tok_kwargs: Mapping[str, object], + ): + mm_kwargs = _presampled_videos_hf_kwargs(mm_data, mm_kwargs) + return super()._call_hf_processor( + prompt=prompt, + mm_data=mm_data, + mm_kwargs=mm_kwargs, + tok_kwargs=tok_kwargs, + ) + def _maybe_apply_prompt_updates( self, mm_items: MultiModalDataItems, 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 0d7f69062..011496f86 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 @@ -74,7 +74,6 @@ from vllm.model_executor.models.module_mapping import MultiModelKeys from vllm.model_executor.models.qwen2_5_omni_thinker import ( Qwen2_5OmniAudioFeatureInputs, Qwen2_5OmniThinkerDummyInputsBuilder, - Qwen2_5OmniThinkerMultiModalProcessor, check_interleaved_audio_video, merge_interleaved_embeddings, ) @@ -120,6 +119,9 @@ from vllm.transformers_utils.processor import cached_processor_from_config from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import ( Qwen2_5OmniConditionalGenerationMixin, + Qwen2_5OmniThinkerMultiModalDataParser, + Qwen2_5OmniThinkerMultiModalProcessor, + _presampled_videos_hf_kwargs, ) from vllm_omni.quantization.component_config import ( PRE_QUANTIZED_METHODS, @@ -621,6 +623,19 @@ class Qwen3OmniMoeThinkerProcessingInfo(Qwen2AudioProcessingInfo, Qwen2_5_VLProc assert isinstance(feature_extractor, WhisperFeatureExtractor) return feature_extractor + def get_data_parser(self): + feature_extractor = self.get_feature_extractor() + + # Install the Omni parser, which keeps video metadata from vLLM's + # video loader (see Qwen2_5OmniVideoProcessorItems); the parser + # inherited from Qwen2AudioProcessingInfo would drop it. + return Qwen2_5OmniThinkerMultiModalDataParser( + spatial_merge_size=self.get_hf_config().vision_config.spatial_merge_size, + target_sr=feature_extractor.sampling_rate, + target_channels=1, + expected_hidden_size=self._get_expected_hidden_size(), + ) + def get_supported_mm_limits(self) -> Mapping[str, int | None]: return {"audio": None, "image": None, "video": None} @@ -671,8 +686,11 @@ class Qwen3OmniMoeThinkerMultiModalProcessor( tok_kwargs: Mapping[str, object], ) -> BatchFeature: mm_data = dict(mm_data) + mm_kwargs = dict(mm_kwargs) audios = mm_data.pop("audios", []) + mm_kwargs = dict(_presampled_videos_hf_kwargs(mm_data, mm_kwargs)) + def pad_to_hop_length(x: np.ndarray, hop_length: int) -> np.ndarray: length = x.shape[-1] if length % hop_length != 0: @@ -927,9 +945,13 @@ class Qwen3OmniMoeThinkerMultiModalProcessor( audio_in_video_item_idx += 1 - second_per_grid_ts = hf_processor_mm_kwargs.get("second_per_grid_ts", None) - if second_per_grid_ts: - video_second_per_grid_t = second_per_grid_ts[item_idx] + # Prefer the HF-computed value (temporal_patch_size / sampled fps) + # over request kwargs; + second_per_grid_ts = out_mm_data.get("second_per_grid_ts") + if second_per_grid_ts is None: + second_per_grid_ts = hf_processor_mm_kwargs.get("second_per_grid_ts", None) + if second_per_grid_ts is not None: + video_second_per_grid_t = float(second_per_grid_ts[item_idx]) else: video_second_per_grid_t = 2.0 From 5c3923509d98ef21a632295c8a2b6a74ab9adde4 Mon Sep 17 00:00:00 2001 From: LHXuuu Date: Fri, 10 Jul 2026 15:06:18 +0800 Subject: [PATCH 058/101] [BugFix][Ming-TTS] Use uploaded direct speaker embeddings by voice (#5005) Signed-off-by: LHXuuu --- .../openai_api/test_serving_speech.py | 106 +++++++++++++++++- .../entrypoints/openai/serving_speech.py | 2 +- .../openai/tts_adapters/ming_tts.py | 15 ++- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index efed9980e..904e5735c 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -39,7 +39,8 @@ from vllm_omni.entrypoints.openai.serving_speech import ( OmniOpenAIServingSpeech, _create_wav_header, ) -from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest +from vllm_omni.entrypoints.openai.tts_adapters.base import PreparedRequest, SpeechServingContext +from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( FISH_TEXT_ONLY_SYSTEM_PROMPT, build_fish_voice_clone_prompt_ids, @@ -973,6 +974,29 @@ class TestTTSMethods: ) ) + def test_upload_ming_voice_embedding_wrong_dims_rejected_without_replacing_existing(self, speech_server): + """Ming embedding uploads must be 192-dim before replacing stored voices.""" + speech_server._tts_model_type = "ming_tts" + existing = { + "name": "bad_emb_voice", + "file_path": "/tmp/voice_samples/bad_emb_voice.safetensors", + "mime_type": "application/x-safetensors", + "embedding_source": "direct", + "embedding_dim": 192, + } + speech_server.uploaded_speakers = {"bad_emb_voice": existing.copy()} + + with pytest.raises(ValueError, match="Ming speaker embedding must have 192 dims, got 191"): + asyncio.run( + speech_server.upload_voice_embedding( + embedding_json=json.dumps([0.0] * 191), + consent="consent", + name="bad_emb_voice", + ) + ) + + assert speech_server.uploaded_speakers["bad_emb_voice"] == existing + def test_base_task_requires_ref_audio_or_speaker_embedding(self, speech_server): """Base task without ref_audio or speaker_embedding is rejected.""" req = OpenAICreateSpeechRequest(input="Hello", task_type="Base") @@ -1557,6 +1581,86 @@ class TestTTSMethods: assert params["x_vector_only_mode"] == [True] assert "ref_audio" not in params + def test_ming_adapter_uploaded_direct_embedding_uses_embedding_not_audio( + self, speech_server, mocker: MockerFixture + ): + """Ming uploaded direct embeddings are loaded into prompt construction.""" + speech_server.uploaded_speakers = { + "emb_voice": { + "name": "emb_voice", + "file_path": "/tmp/voice_samples/emb_voice.safetensors", + "mime_type": "application/x-safetensors", + "embedding_source": "direct", + "embedding_dim": 192, + } + } + fake_embedding = [0.1] * 192 + mock_get_emb = mocker.patch.object( + speech_server, "_get_uploaded_speaker_embedding", return_value=fake_embedding + ) + mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data") + mock_prompt = mocker.patch.object( + speech_server, + "_build_ming_dense_prompt", + return_value={"additional_information": {"speaker_count": 1}}, + ) + + adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) + req = OpenAICreateSpeechRequest(input="Hello", voice="emb_voice") + prepared = asyncio.run(adapter.build(req, [], False)) + + mock_get_emb.assert_called_once_with("emb_voice") + mock_get_audio.assert_not_called() + mock_prompt.assert_called_once_with(req, ref_audio_data=None) + prompt_request = mock_prompt.call_args.args[0] + assert prompt_request.speaker_embedding == fake_embedding + assert len(prompt_request.speaker_embedding) == 192 + assert prepared.prompt["additional_information"]["speaker_count"] == 1 + assert prepared.model_type == "ming_tts" + + def test_ming_adapter_uploaded_audio_path_preserves_ref_text(self, speech_server, mocker: MockerFixture): + """Ming uploaded audio voices still resolve audio and stored ref_text.""" + speech_server.uploaded_speakers = { + "audio_voice": { + "name": "audio_voice", + "file_path": "/tmp/voice_samples/audio_voice.wav", + "mime_type": "audio/wav", + "ref_text": "Reference transcript.", + } + } + ref_audio_source = "data:audio/wav;base64,ZmFrZWF1ZGlv" + ref_audio_data = ([0.0, 0.1], 16000) + fake_embedding = [0.2] * 192 + mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data", return_value=ref_audio_source) + mock_get_emb = mocker.patch.object(speech_server, "_get_uploaded_speaker_embedding") + mocker.patch.object( + speech_server, + "_resolve_ref_audio", + new=mocker.AsyncMock(return_value=ref_audio_data), + ) + mock_extract = mocker.patch.object( + speech_server, + "_extract_ming_speaker_embeddings_from_ref_audio", + return_value=[fake_embedding], + ) + mock_prompt = mocker.patch.object( + speech_server, + "_build_ming_dense_prompt", + return_value={"additional_information": {"speaker_count": 1}}, + ) + + adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) + req = OpenAICreateSpeechRequest(input="Hello", voice="audio_voice") + prepared = asyncio.run(adapter.build(req, [], False)) + + mock_get_audio.assert_called_once_with("audio_voice") + mock_get_emb.assert_not_called() + mock_extract.assert_called_once_with([ref_audio_data]) + mock_prompt.assert_called_once_with(req, ref_audio_data=ref_audio_data) + assert req.ref_text == "Reference transcript." + assert req.speaker_embedding == fake_embedding + assert prepared.model_type == "ming_tts" + # ── regression: full flow from issue #1603 ── def test_regression_1603_speaker_key_with_uploaded_audio_voice(self, speech_server, mocker: MockerFixture): diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 6bfedccba..2e9594c02 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -1395,7 +1395,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): emb_dim = len(embedding) if self._tts_model_type == "ming_tts": if emb_dim != 192: - logger.warning("speaker_embedding has %d dimensions; Ming dense expects 192", emb_dim) + raise ValueError(f"Ming speaker embedding must have 192 dims, got {emb_dim}") else: dim_err = self._validate_qwen_tts_speaker_embedding_dim(emb_dim) if dim_err is not None: diff --git a/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py index 37318a521..7e1a8e66b 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py @@ -25,9 +25,18 @@ class MingTTSAdapter(ARTTSAdapter): 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 server.uploaded_speakers: - ref_audio_source = server._get_uploaded_audio_data(request.voice) - if request.ref_text is None: - request.ref_text = server.uploaded_speakers[voice_lower].get("ref_text") + speaker_info = server.uploaded_speakers[voice_lower] + if speaker_info.get("embedding_source") == "direct": + if request.speaker_embedding is None: + request.speaker_embedding = server._get_uploaded_speaker_embedding(request.voice) + if request.speaker_embedding is None: + raise ValueError(f"Speaker embedding for uploaded voice '{request.voice}' is missing") + else: + ref_audio_source = server._get_uploaded_audio_data(request.voice) + if not ref_audio_source: + raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing") + if request.ref_text is None: + request.ref_text = speaker_info.get("ref_text") ref_audio_data = None if isinstance(ref_audio_source, list): ref_audio_data = await server._resolve_ref_audio_many(ref_audio_source) From 9a44e7e019a90c4700cd06244bdf11791825843a Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Fri, 10 Jul 2026 15:13:35 +0800 Subject: [PATCH 059/101] [Refactor][Phase 1]Remove redundant functions and logs (#4986) Signed-off-by: amy-why-3459 --- docs/configuration/pd_disaggregation.md | 4 +- docs/contributing/model/adding_omni_model.md | 120 ++++----- .../test_cosyvoice3_stage_input_processors.py | 15 +- .../stage_input_processors/test_indextts2.py | 113 -------- .../test_mimo_audio_llm2code2wav.py | 43 +-- .../test_qwen3_omni_streaming_helpers.py | 8 +- .../test_qwen3_tts_async_chunk.py | 44 ++- .../test_voxtral_tts_async_chunk.py | 49 ---- tests/test_config_factory.py | 7 +- vllm_omni/deploy/qwen3_omni_moe.yaml | 2 - vllm_omni/engine/stage_engine_core_client.py | 2 +- .../models/cosyvoice3/pipeline.py | 6 +- .../models/mimo_audio/pipeline.py | 1 - .../models/qwen3_omni/pipeline.py | 2 - .../stage_input_processors/cosyvoice3.py | 24 -- .../stage_input_processors/fish_speech.py | 31 --- .../stage_input_processors/indextts2.py | 59 ---- .../stage_input_processors/mimo_audio.py | 89 +------ .../stage_input_processors/qwen3_omni.py | 251 +++--------------- .../stage_input_processors/qwen3_tts.py | 117 +------- .../stage_input_processors/voxtral_tts.py | 25 -- vllm_omni/worker/gpu_ar_model_runner.py | 8 +- vllm_omni/worker/gpu_model_runner.py | 19 +- .../omni_connector_model_runner_mixin.py | 2 +- 24 files changed, 162 insertions(+), 879 deletions(-) diff --git a/docs/configuration/pd_disaggregation.md b/docs/configuration/pd_disaggregation.md index 9196bdb02..7deb0443a 100644 --- a/docs/configuration/pd_disaggregation.md +++ b/docs/configuration/pd_disaggregation.md @@ -123,7 +123,7 @@ After inserting the extra thinker stage, renumber the remaining stages: runtime: devices: "2" engine_input_source: [1] - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker + sync_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_token_only - stage_id: 3 runtime: @@ -131,7 +131,7 @@ After inserting the extra thinker stage, renumber the remaining stages: engine_args: max_num_seqs: 1 engine_input_source: [2] - custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav + sync_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_token_only ``` Compared with the default Qwen3-Omni config: diff --git a/docs/contributing/model/adding_omni_model.md b/docs/contributing/model/adding_omni_model.md index 1eaff1059..c20e94eda 100644 --- a/docs/contributing/model/adding_omni_model.md +++ b/docs/contributing/model/adding_omni_model.md @@ -475,92 +475,64 @@ thinker_hidden_states = output.multimodal_output["24"] ### Implementation Example -Create stage transition processors in `vllm_omni/model_executor/stage_input_processors/your_model_name.py`: +Create stage transition processors in `vllm_omni/model_executor/stage_input_processors/your_model_name.py`. Each inter-stage edge should provide a **coherent processor set** rather than a single monolithic function: + +| Suffix | Role | Runs when | +|--------|------|-----------| +| `*_full_payload` | Worker-side payload producer | `async_chunk=false`; accumulates tensors and ships via connector | +| `*_async_chunk` | Scheduler-side streaming producer | `async_chunk=true`; emits per-chunk payloads | +| `*_token_only` | Orchestrator placeholder builder | `async_chunk=false`; allocates downstream prompt slots only | ```python -# qwen3_omni.py +# qwen3_omni.py (Thinker → Talker, non-async path) -def thinker2talker( - stage_list: list[Any], - engine_input_source: list[int], +def thinker2talker_token_only( + source_outputs: list[Any], prompt: OmniTokensPrompt | TextPrompt | None = None, requires_multimodal_data: bool = False, + streaming_context: Any | None = None, ) -> list[OmniTokensPrompt]: - """ - Process thinker outputs to create talker inputs. - - Args: - stage_list: List of stage objects - engine_input_source: Source stage IDs (typically [0] for thinker) - prompt: Original prompt data - - Returns: - List of OmniTokensPrompt for talker stage - """ - source_stage_id = engine_input_source[0] - thinker_outputs = stage_list[source_stage_id].engine_outputs - talker_inputs = [] - - for thinker_output in thinker_outputs: - output = thinker_output.outputs[0] - # Extract thinker embeddings and hidden states - thinker_prefill_embeddings = output.multimodal_output["0"].float().clone().detach().cuda() - thinker_hidden_states = output.multimodal_output["24"].float().clone().detach().cuda() - - info = { - "thinker_prefill_embeddings": thinker_prefill_embeddings, - "thinker_hidden_states": thinker_hidden_states, - "thinker_sequences": thinker_output.prompt_token_ids + output.token_ids, - "thinker_input_ids": thinker_output.prompt_token_ids, - } - - talker_inputs.append( - OmniTokensPrompt( - prompt_token_ids=[0] * computed_length, - additional_information=info, - multi_modal_data=None, - ) - ) - - return talker_inputs + """Allocate talker prefill slots; bulk tensors arrive via the connector.""" + ... -def talker2code2wav( - stage_list: list[Any], - engine_input_source: list[int], - prompt: OmniTokensPrompt | TextPrompt | None = None, - requires_multimodal_data: bool = False, -) -> list[OmniTokensPrompt]: - """ - Process talker outputs to create code2wav inputs. - """ - source_stage_id = engine_input_source[0] - talker_outputs = stage_list[source_stage_id].engine_outputs - code2wav_inputs = [] +def thinker2talker_full_payload( + transfer_manager: Any, + pooling_output: dict[str, Any], + request: OmniEngineCoreRequest, +) -> dict[str, Any] | None: + """Pack accumulated thinker hidden states into OmniPayload for stage-1.""" + ... - for talker_output in talker_outputs: - output = talker_output.outputs[0] - # Extract codec codes - codec_codes = ( - output.multimodal_output["code_predictor_codes"] - .to(torch.long) - .transpose(0, 1) - .cpu() - .to(torch.long) - .reshape(-1) - .tolist() - ) - code2wav_inputs.append( - OmniTokensPrompt( - prompt_token_ids=codec_codes, - multi_modal_data=None, - ) - ) - - return code2wav_inputs +def thinker2talker_async_chunk( + transfer_manager: Any, + multimodal_output: OmniPayload | dict[str, Any], + request: OmniEngineCoreRequest, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Stream thinker rows to talker while async_chunk is enabled.""" + ... ``` +Wire these in `pipeline.py`: + +```python +StagePipelineConfig( + stage_id=0, + custom_process_next_stage_input_func=f"{_PROC}.thinker2talker_full_payload", + async_chunk_process_next_stage_input_func=f"{_PROC}.thinker2talker_async_chunk", + ... +), +StagePipelineConfig( + stage_id=1, + sync_process_input_func=f"{_PROC}.thinker2talker_token_only", + ... +), +``` + +Do **not** add a no-suffix `thinker2talker` when a `sync_process_input_func` is already declared — `_select_processor_funcs()` always prefers the `*_token_only` hook in non-async mode, so the bare function would never run. See `docs/design/rfc_stage_input_processors_refactor.md` for the full contract. + ## Testing For comprehensive testing guidelines, please refer to the [Test File Structure and Style Guide](../ci/tests_style.md). diff --git a/tests/model_executor/stage_input_processors/test_cosyvoice3_stage_input_processors.py b/tests/model_executor/stage_input_processors/test_cosyvoice3_stage_input_processors.py index 83168b4f5..5309c7675 100644 --- a/tests/model_executor/stage_input_processors/test_cosyvoice3_stage_input_processors.py +++ b/tests/model_executor/stage_input_processors/test_cosyvoice3_stage_input_processors.py @@ -8,7 +8,6 @@ import torch from vllm_omni.model_executor.stage_input_processors.cosyvoice3 import ( talker2code2wav_async_chunk, - text2flow, text2flow_full_payload, text2flow_token_only, ) @@ -49,13 +48,13 @@ def _transfer_manager( ) -def test_text2flow_supports_batched_source_outputs(): +def test_text2flow_token_only_supports_batched_source_outputs(): source_outputs = [ _source_output("req-0", [10, 11], [1, 2, 3], {"speech_token": torch.tensor([[8, 9]])}), _source_output("req-1", [20, 21], [4, 5], {"speech_token": torch.tensor([[6, 7]])}), ] - outputs = text2flow(source_outputs=source_outputs, prompt=None) + outputs = text2flow_token_only(source_outputs=source_outputs, prompt=None) assert len(outputs) == 2 assert outputs[0]["prompt_token_ids"] == [1, 2, 3] @@ -64,16 +63,6 @@ def test_text2flow_supports_batched_source_outputs(): assert outputs[1]["additional_information"]["ids"]["prompt"] == [20, 21] -def test_text2flow_strips_reference_speech_prefix_from_cumulative_ids(): - source_outputs = [ - _source_output("req-0", [10, 11], [8, 9, 1, 2, 3], {"speech_token": torch.tensor([[8, 9]])}), - ] - - outputs = text2flow(source_outputs=source_outputs, prompt=None) - - assert outputs[0]["prompt_token_ids"] == [1, 2, 3] - - def test_text2flow_token_only_strips_reference_speech_prefix_from_cumulative_ids(): source_outputs = [ _source_output( diff --git a/tests/model_executor/stage_input_processors/test_indextts2.py b/tests/model_executor/stage_input_processors/test_indextts2.py index 59f14b414..11468603e 100644 --- a/tests/model_executor/stage_input_processors/test_indextts2.py +++ b/tests/model_executor/stage_input_processors/test_indextts2.py @@ -7,7 +7,6 @@ import torch from vllm_omni.model_executor.stage_input_processors.indextts2 import ( _strip_stop_token, - talker2s2mel, talker2s2mel_full_payload, talker2s2mel_token_only, ) @@ -108,118 +107,6 @@ def test_strip_stop_token_all_stop(): assert lat.shape[1] == 0 -# ── talker2s2mel ── - - -def test_talker2s2mel_basic(): - mel_codes = torch.tensor([10, 20, 30, STOP_MEL_TOKEN]) - latent = torch.randn(4, LATENT_DIM) - s_ref = torch.randn(1, 128) - ref_mel = torch.randn(1, 80, 200) - style = torch.randn(1, 256) - - outputs = talker2s2mel( - source_outputs=[ - _make_talker_output( - mel_codes=mel_codes, - latent=latent, - s_ref=s_ref, - ref_mel=ref_mel, - style=style, - ) - ] - ) - - assert len(outputs) == 1 - info = outputs[0]["additional_information"] - assert info["mel_codes"].shape == (1, 3) - assert info["latent"].shape == (1, 3, LATENT_DIM) - assert info["code_lens"].tolist() == [3] - assert "S_ref" in info - assert "ref_mel" in info - assert "style" in info - assert outputs[0]["prompt_token_ids"] == [0] - - -def test_talker2s2mel_skips_unfinished(): - mel_codes = torch.tensor([10, 20]) - latent = torch.randn(2, LATENT_DIM) - - outputs = talker2s2mel(source_outputs=[_make_talker_output(mel_codes=mel_codes, latent=latent, finished=False)]) - - assert len(outputs) == 0 - - -def test_talker2s2mel_skips_empty_mel(): - outputs = talker2s2mel( - source_outputs=[ - _make_talker_output( - mel_codes=torch.tensor([]), - latent=torch.randn(0, LATENT_DIM), - ) - ] - ) - - assert len(outputs) == 0 - - -def test_talker2s2mel_missing_optional_meta(): - mel_codes = torch.tensor([10, 20]) - latent = torch.randn(2, LATENT_DIM) - - outputs = talker2s2mel(source_outputs=[_make_talker_output(mel_codes=mel_codes, latent=latent)]) - - assert len(outputs) == 1 - info = outputs[0]["additional_information"] - assert "S_ref" not in info - assert "ref_mel" not in info - assert "style" not in info - - -def test_talker2s2mel_batch(): - out0 = _make_talker_output( - mel_codes=torch.tensor([1, 2, STOP_MEL_TOKEN]), - latent=torch.randn(3, LATENT_DIM), - s_ref=torch.randn(1, 64), - ) - out1 = _make_talker_output( - mel_codes=torch.tensor([5, 6, 7, 8]), - latent=torch.randn(4, LATENT_DIM), - ref_mel=torch.randn(1, 80, 100), - ) - - outputs = talker2s2mel(source_outputs=[out0, out1]) - - assert len(outputs) == 2 - assert outputs[0]["additional_information"]["code_lens"].tolist() == [2] - assert outputs[1]["additional_information"]["code_lens"].tolist() == [4] - - -def test_talker2s2mel_tensors_on_cpu(): - device = torch.device("cpu") - mel_codes = torch.tensor([10, 20], device=device) - latent = torch.randn(2, LATENT_DIM, device=device) - s_ref = torch.randn(1, 64, device=device) - - outputs = talker2s2mel(source_outputs=[_make_talker_output(mel_codes=mel_codes, latent=latent, s_ref=s_ref)]) - - info = outputs[0]["additional_information"] - assert info["latent"].device.type == "cpu" - assert info["mel_codes"].device.type == "cpu" - assert info["S_ref"].device.type == "cpu" - - -def test_talker2s2mel_no_multimodal_dict(): - output = SimpleNamespace( - finished=True, - outputs=[SimpleNamespace(multimodal_output="not_a_dict")], - ) - - results = talker2s2mel(source_outputs=[output]) - - assert len(results) == 0 - - # ── full-payload connector path ── diff --git a/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py b/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py index d1cc0dbf7..ae1c8e2f9 100644 --- a/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py +++ b/tests/model_executor/stage_input_processors/test_mimo_audio_llm2code2wav.py @@ -11,59 +11,40 @@ import torch from vllm_omni.model_executor.stage_input_processors import mimo_audio as sip from vllm_omni.model_executor.stage_input_processors.mimo_audio import ( MAX_CODE2WAV_TOKENS, - llm2code2wav, + llm2code2wav_full_payload, ) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -def _make_source_outputs(codec_codes: torch.Tensor, request_id: str = "req-0"): - output = SimpleNamespace(multimodal_output={"codes": {"audio": codec_codes}}) - """Build minimal source_outputs with one talker output carrying codec codes.""" - talker_output = SimpleNamespace(outputs=[output], request_id=request_id) - return [talker_output] - - -def test_llm2code2wav_truncates_when_flat_exceeds_max(caplog): - """Flat codec sequences longer than MAX_CODE2WAV_TOKENS must be truncated, not passed through.""" - # prepend_and_flatten_colmajor produces 36 ids per (8, 4) codec frame: - # pad adds one row -> (9, 4) per frame, permuted and flattened. - # Pick enough frames to comfortably exceed the cap. +def test_llm2code2wav_full_payload_truncates_when_flat_exceeds_max(caplog): + """Flat codec sequences longer than MAX_CODE2WAV_TOKENS must be truncated.""" frames = (MAX_CODE2WAV_TOKENS // 36) + 100 codec_codes = torch.ones(frames, 1, 8, 4, dtype=torch.long) + request = SimpleNamespace(request_id="req-long") - source_outputs = _make_source_outputs(codec_codes, request_id="req-long") - - # Attach caplog's handler directly to the module logger so the warning is - # captured regardless of propagation (vllm's logger configuration can - # interact badly with caplog.at_level's default root-handler path). target_logger = logging.getLogger("vllm_omni.model_executor.stage_input_processors.mimo_audio") target_logger.addHandler(caplog.handler) prev_level = target_logger.level target_logger.setLevel(logging.WARNING) try: - prompts = llm2code2wav(source_outputs) + payload = llm2code2wav_full_payload(None, {"codes.audio": codec_codes}, request) finally: target_logger.removeHandler(caplog.handler) target_logger.setLevel(prev_level) - assert len(prompts) == 1 - assert len(prompts[0]["prompt_token_ids"]) == MAX_CODE2WAV_TOKENS - assert any("truncating" in rec.getMessage() for rec in caplog.records), ( - f"Expected a 'truncating' warning; captured records: {[r.getMessage() for r in caplog.records]}" - ) + assert payload is not None + assert len(payload["codes"]["audio"]) == MAX_CODE2WAV_TOKENS -def test_llm2code2wav_short_sequence_unchanged(): - """Short codec sequences are returned without truncation.""" +def test_llm2code2wav_full_payload_short_sequence_unchanged(): codec_codes = torch.ones(4, 1, 8, 4, dtype=torch.long) - source_outputs = _make_source_outputs(codec_codes, request_id="req-short") + request = SimpleNamespace(request_id="req-short") - prompts = llm2code2wav(source_outputs) + payload = llm2code2wav_full_payload(None, {"codes.audio": codec_codes}, request) - assert len(prompts) == 1 - # 4 frames + 1 pad row, flattened col-major → well below the cap - assert 0 < len(prompts[0]["prompt_token_ids"]) <= MAX_CODE2WAV_TOKENS + assert payload is not None + assert 0 < len(payload["codes"]["audio"]) <= MAX_CODE2WAV_TOKENS def test_llm2code2wav_truncation_boundary_constant_matches_yaml(): 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 69b612a05..afc71c476 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 @@ -163,7 +163,7 @@ def test_talker2code2wav_full_payload_filters_by_output_token_ids() -> None: assert payload is not None assert payload["codes"]["audio"] == [10, 20, 11, 21, 12, 22] - assert payload["code_predictor_codes"] == payload["codes"]["audio"] + assert "code_predictor_codes" not in payload def test_talker2code2wav_full_payload_drops_count_matched_terminal_row() -> None: @@ -204,7 +204,7 @@ def test_talker2code2wav_full_payload_drops_rows_aligned_to_non_codec_ids() -> N assert payload is not None assert payload["codes"]["audio"] == [0, 0, 0] - assert payload["code_predictor_codes"] == payload["codes"]["audio"] + assert "code_predictor_codes" not in payload def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None: @@ -224,7 +224,7 @@ def test_talker2code2wav_full_payload_keeps_all_zero_codec_rows() -> None: assert payload is not None assert payload["codes"]["audio"] == [0, 7, 0, 8, 0, 9] - assert payload["code_predictor_codes"] == payload["codes"]["audio"] + assert "code_predictor_codes" not in payload def test_thinker2talker_full_payload_packs_complete_tensors() -> None: @@ -247,7 +247,7 @@ def test_thinker2talker_full_payload_packs_complete_tensors() -> None: assert payload["ids"]["all"] == [151644, 872, 3] assert payload["embed"]["prefill"].device.type == "cpu" assert payload["hidden_states"]["output"].device.type == "cpu" - assert payload["next_stage_prompt_len"] > 0 + assert payload["meta"]["next_stage_prompt_len"] > 0 assert payload["embed"]["prefill"].shape[0] == 2 assert payload["hidden_states"]["output"].shape[0] == 2 diff --git a/tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.py b/tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.py index 82f7da711..bc6239e29 100644 --- a/tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.py +++ b/tests/model_executor/stage_input_processors/test_qwen3_tts_async_chunk.py @@ -12,9 +12,9 @@ from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import ( max_ic_for_chunk_size, ) from vllm_omni.model_executor.stage_input_processors.qwen3_tts import ( - talker2code2wav, talker2code2wav_async_chunk, talker2code2wav_full_payload, + talker2code2wav_token_only, ) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -368,7 +368,15 @@ def test_ref_code_context_can_be_buffered_before_first_emit(): assert rid in tm.request_payload -def test_non_async_processor_prepends_ref_code_and_sets_trim_context(): +def test_non_async_token_only_sizes_placeholder_for_ref_and_audio_frames(): + """``talker2code2wav_token_only`` only allocates placeholder prompt slots. + + After the connector refactor, actual codec flattening (ref prepend + + codebook-major layout) is performed by ``talker2code2wav_full_payload`` on + the worker data plane. The orchestrator hook still derives + ``left_context_size`` from stage-0 multimodal_output so Code2Wav can trim + reference frames once the connector payload arrives. + """ ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long) audio_codes = torch.tensor( [ @@ -387,12 +395,38 @@ def test_non_async_processor_prepends_ref_code_and_sets_trim_context(): engine_outputs=[SimpleNamespace(outputs=[output], finished=True)], ) - prompts = talker2code2wav(stage.engine_outputs) + prompts = talker2code2wav_token_only(stage.engine_outputs) assert len(prompts) == 1 prompt = prompts[0] assert prompt["additional_information"] == {"meta": {"left_context_size": 2}} - assert prompt["prompt_token_ids"] == [ + # 2 ref frames + 2 valid audio frames (zero row filtered), 4 quantizers. + assert prompt["prompt_token_ids"] == [0] * (_Q * (2 + 2)) + + +def test_full_payload_prepends_ref_code_and_flattens_codebook_major(): + """Worker producer is authoritative for ref prepend + codec flatten.""" + ref_code = torch.tensor([[9, 9, 9, 9], [8, 8, 8, 8]], dtype=torch.long) + audio_codes = torch.tensor( + [ + [0, 0, 0, 0], + [1, 2, 3, 4], + [5, 6, 7, 8], + ], + dtype=torch.long, + ) + pooling_output = { + "codes.audio": audio_codes, + "codes.ref": ref_code, + "meta.ref_code_len": 2, + } + request = SimpleNamespace(request_id="r", output_token_ids=list(range(3))) + + payload = talker2code2wav_full_payload(transfer_manager=None, pooling_output=pooling_output, request=request) + + assert payload is not None + assert payload["meta"]["left_context_size"] == 2 + assert payload["codes"]["audio"].tolist() == [ 9, 8, 1, @@ -433,7 +467,7 @@ def test_non_async_processor_filters_out_of_range_codec_values(): engine_outputs=[SimpleNamespace(outputs=[output], finished=True)], ) - prompts = talker2code2wav(stage.engine_outputs) + prompts = talker2code2wav_token_only(stage.engine_outputs) assert len(prompts) == 1 prompt = prompts[0] diff --git a/tests/model_executor/stage_input_processors/test_voxtral_tts_async_chunk.py b/tests/model_executor/stage_input_processors/test_voxtral_tts_async_chunk.py index 3fd5675ce..dc248f211 100644 --- a/tests/model_executor/stage_input_processors/test_voxtral_tts_async_chunk.py +++ b/tests/model_executor/stage_input_processors/test_voxtral_tts_async_chunk.py @@ -8,61 +8,12 @@ import pytest import torch from vllm_omni.model_executor.stage_input_processors.voxtral_tts import ( - generator2tokenizer, generator2tokenizer_async_chunk, ) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -# ---- Helpers for generator2tokenizer (non-streaming) ---- - - -def _make_stage(engine_outputs): - return SimpleNamespace(engine_outputs=engine_outputs) - - -def _make_output(audio_tensors): - """Build a single generator output with multimodal_output codes.audio.""" - return SimpleNamespace( - outputs=[SimpleNamespace(multimodal_output={"codes": {"audio": audio_tensors}})], - ) - - -# ---- Tests for generator2tokenizer ---- - - -class TestGenerator2Tokenizer: - def test_single_output_single_chunk(self): - """Single output with one audio tensor produces correct prompt_token_ids.""" - audio = torch.tensor([10, 20, 30, 40]) - stage = _make_stage([_make_output([audio])]) - - result = generator2tokenizer(stage.engine_outputs) - - assert len(result) == 1 - assert result[0]["prompt_token_ids"] == [10, 20, 30, 40] - assert result[0]["multi_modal_data"] is None - - def test_2d_audio_tensors_flattened(self): - """2D audio tensors (e.g., multi-codebook frames) are flattened.""" - audio = torch.tensor([[1, 2, 3], [4, 5, 6]]) # (2, 3) - stage = _make_stage([_make_output([audio])]) - - result = generator2tokenizer(stage.engine_outputs) - - assert result[0]["prompt_token_ids"] == [1, 2, 3, 4, 5, 6] - - def test_no_outputs_yet_raises(self): - """Stage with engine_outputs=None raises RuntimeError.""" - stage = _make_stage(engine_outputs=None) - with pytest.raises(TypeError): - generator2tokenizer(stage.engine_outputs) - - -# ---- Helpers for generator2tokenizer_async_chunk (streaming) ---- - - def _req(external_req_id: str, *, finished: bool): return SimpleNamespace( external_req_id=external_req_id, diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index 6cf288b4c..c1c76180a 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -1166,7 +1166,10 @@ class TestQwen3OmniPipeline: assert isinstance(s, StagePipelineConfig) assert s.input_sources == (0,) assert s.sampling_constraints["stop_token_ids"] == [2150] - assert s.custom_process_input_func is not None + # thinker2talker was removed: sync_process_input_func always wins. + assert s.custom_process_input_func is None + assert s.sync_process_input_func is not None + assert s.sync_process_input_func.endswith("thinker2talker_token_only") assert s.custom_process_next_stage_input_func is not None def test_code2wav(self): @@ -1180,7 +1183,7 @@ class TestQwen3OmniPipeline: assert isinstance(s, StagePipelineConfig) assert s.execution_type == StageExecutionType.LLM_GENERATION assert s.final_output_type == "audio" - assert s.custom_process_input_func is not None + assert s.engine_output_type == "audio" class TestQwen2_5OmniPipeline: diff --git a/vllm_omni/deploy/qwen3_omni_moe.yaml b/vllm_omni/deploy/qwen3_omni_moe.yaml index f91eea5a8..5bcbb0fe3 100644 --- a/vllm_omni/deploy/qwen3_omni_moe.yaml +++ b/vllm_omni/deploy/qwen3_omni_moe.yaml @@ -47,7 +47,6 @@ stages: temperature: 0.9 top_k: 50 max_tokens: 4096 - seed: 42 repetition_penalty: 1.05 - stage_id: 2 @@ -67,7 +66,6 @@ stages: top_p: 1.0 top_k: -1 max_tokens: 65536 - seed: 42 repetition_penalty: 1.1 platforms: diff --git a/vllm_omni/engine/stage_engine_core_client.py b/vllm_omni/engine/stage_engine_core_client.py index 78c209b0b..5fbbbc46d 100644 --- a/vllm_omni/engine/stage_engine_core_client.py +++ b/vllm_omni/engine/stage_engine_core_client.py @@ -230,7 +230,7 @@ class StageEngineCoreClientBase(StageClientBase): async def add_request_async(self, request: EngineCoreRequest) -> None: """Add request to the stage engine core.""" - logger.info( + logger.debug( "[%s] stage-%s [rep-%s] add request: %s", self.__class__.__name__, self.stage_id, diff --git a/vllm_omni/model_executor/models/cosyvoice3/pipeline.py b/vllm_omni/model_executor/models/cosyvoice3/pipeline.py index 32db8ef5b..a8d51fdfe 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/pipeline.py +++ b/vllm_omni/model_executor/models/cosyvoice3/pipeline.py @@ -5,9 +5,8 @@ Stage 0: Talker — text prompt → speech tokens (LLM autoregressive). Stage 1: Code2Wav — flow-matching decoder → acoustic features → waveform. * ``sync_process_input_func`` (``text2flow_token_only``) runs when - ``deploy.async_chunk=false``: stage 1 builds full-sequence flow input - from the emitted speech tokens. ``text2flow`` is the - ``custom_process_input_func`` variant. + ``deploy.async_chunk=false``: stage 1 allocates placeholder slots while + bulk tensors arrive via ``text2flow_full_payload`` on the connector. * ``async_chunk_process_next_stage_input_func`` runs when ``deploy.async_chunk=true``: stage 0 streams codec chunks to stage 1 through the shared-memory connector. @@ -47,7 +46,6 @@ COSYVOICE3_PIPELINE = PipelineConfig( final_output=True, final_output_type="audio", engine_output_type="latent", - custom_process_input_func=f"{_PROC}.text2flow", sync_process_input_func=f"{_PROC}.text2flow_token_only", ), ), diff --git a/vllm_omni/model_executor/models/mimo_audio/pipeline.py b/vllm_omni/model_executor/models/mimo_audio/pipeline.py index 126c90176..9de6575d9 100644 --- a/vllm_omni/model_executor/models/mimo_audio/pipeline.py +++ b/vllm_omni/model_executor/models/mimo_audio/pipeline.py @@ -56,7 +56,6 @@ MIMO_AUDIO_PIPELINE = PipelineConfig( final_output=True, final_output_type="audio", engine_output_type="audio", - custom_process_input_func=f"{_PROC}.llm2code2wav", sync_process_input_func=f"{_PROC}.llm2code2wav_token_only", sampling_constraints={"detokenize": False}, ), diff --git a/vllm_omni/model_executor/models/qwen3_omni/pipeline.py b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py index 137e4e29d..ca3a09f89 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/pipeline.py +++ b/vllm_omni/model_executor/models/qwen3_omni/pipeline.py @@ -51,7 +51,6 @@ QWEN3_OMNI_PIPELINE = PipelineConfig( input_sources=(0,), hf_config_name="talker_config", engine_output_type="latent", - custom_process_input_func=f"{_PROC}.thinker2talker", sync_process_input_func=f"{_PROC}.thinker2talker_token_only", custom_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_full_payload"), async_chunk_process_next_stage_input_func=(f"{_PROC}.talker2code2wav_async_chunk"), @@ -69,7 +68,6 @@ QWEN3_OMNI_PIPELINE = PipelineConfig( final_output_type="audio", hf_config_name="thinker_config", engine_output_type="audio", - custom_process_input_func=f"{_PROC}.talker2code2wav", sampling_constraints={"detokenize": True}, ), ), diff --git a/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py b/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py index 0477ea4e1..2956be94f 100644 --- a/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py +++ b/vllm_omni/model_executor/stage_input_processors/cosyvoice3.py @@ -119,30 +119,6 @@ def _decode_additional_information(raw_info: Any) -> dict[str, Any]: return decoded -def text2flow( - source_outputs: list[Any], - prompt: OmniTokensPrompt | TextPrompt = None, - _requires_multimodal_data: bool = True, -): - """Build stage-1 inputs by prefixing stage-0 prompt ids to its outputs.""" - engine_inputs: list[OmniTokensPrompt] = [] - for source_output in source_outputs: - output = source_output.outputs[0] - multi_modal_data = output.multimodal_output - if multi_modal_data is None: - raise RuntimeError(f"Missing multimodal_output for request {source_output.request_id}") - - prefix_ids = _ensure_list(source_output.prompt_token_ids) - raw_output_ids = _ensure_list(output.cumulative_token_ids) - prompt_speech_ids = _prompt_speech_token_ids(multi_modal_data) - output_ids = _strip_prompt_prefix(raw_output_ids, prefix_ids) - output_ids = _strip_prompt_prefix(output_ids, prompt_speech_ids) - additional_info = dict(multi_modal_data) - additional_info.setdefault("ids", {})["prompt"] = prefix_ids - engine_inputs.append(OmniTokensPrompt(prompt_token_ids=output_ids, additional_information=additional_info)) - return engine_inputs - - def talker2code2wav_async_chunk( transfer_manager: Any, multimodal_output: dict[str, Any] | None, diff --git a/vllm_omni/model_executor/stage_input_processors/fish_speech.py b/vllm_omni/model_executor/stage_input_processors/fish_speech.py index 668ff2ed4..f428c34a4 100644 --- a/vllm_omni/model_executor/stage_input_processors/fish_speech.py +++ b/vllm_omni/model_executor/stage_input_processors/fish_speech.py @@ -114,37 +114,6 @@ def _extract_last_frame(multimodal_output: dict[str, Any]) -> torch.Tensor | Non raise ValueError(f"Invalid audio_codes shape for Fish Speech async_chunk: {tuple(audio_codes.shape)}") -def slow_ar_to_dac_decoder( - source_outputs: list[Any], - _prompt: Any = None, - _requires_multimodal_data: bool = False, -) -> list[Any]: - """Non-async processor: wait for Slow AR to finish, then pass all codes to DAC decoder.""" - from vllm_omni.inputs.data import OmniTokensPrompt - - slow_ar_outputs = source_outputs - dac_inputs: list[OmniTokensPrompt] = [] - - for output in slow_ar_outputs: - out = output.outputs[0] - # audio_codes shape: [num_frames, num_codebooks] - audio_codes = out.multimodal_output["audio_codes"].to(torch.long) - # Filter zero-padded frames. - valid_mask = audio_codes.any(dim=1) - audio_codes = audio_codes[valid_mask] - # Codebook-major flat: [num_codebooks * num_frames] - codec_codes = audio_codes.transpose(0, 1).cpu().reshape(-1).tolist() - dac_inputs.append( - OmniTokensPrompt( - prompt_token_ids=codec_codes, - multi_modal_data=None, - mm_processor_kwargs=None, - additional_information=None, - ) - ) - return dac_inputs - - def slow_ar_to_dac_decoder_async_chunk( transfer_manager: Any, multimodal_output: dict[str, Any] | None, diff --git a/vllm_omni/model_executor/stage_input_processors/indextts2.py b/vllm_omni/model_executor/stage_input_processors/indextts2.py index 9d38c8f2a..39d9d011c 100644 --- a/vllm_omni/model_executor/stage_input_processors/indextts2.py +++ b/vllm_omni/model_executor/stage_input_processors/indextts2.py @@ -143,65 +143,6 @@ def _request_id(request: Any) -> str: return str(getattr(request, "external_req_id", None) or getattr(request, "request_id", "?")) -def talker2s2mel( - source_outputs: list[Any], - prompt: Any = None, - _requires_multimodal_data: bool = False, -) -> list[Any]: - """Legacy orchestrator path: collect all Stage 0 output, format Stage 1 input.""" - _old_nt = torch.get_num_threads() - torch.set_num_threads(1) - try: - return _talker2s2mel_impl(source_outputs) - finally: - torch.set_num_threads(_old_nt) - - -def _talker2s2mel_impl(source_outputs: list[Any]) -> list[Any]: - from vllm_omni.inputs.data import OmniTokensPrompt - - s2mel_inputs: list[OmniTokensPrompt] = [] - - for i, talker_output in enumerate(source_outputs): - if not talker_output.finished: - continue - - output = talker_output.outputs[0] - mm = output.multimodal_output - if not isinstance(mm, dict): - logger.warning("Talker output %d has no multimodal_output dict", i) - continue - - codes_dict = mm.get("codes", {}) - mel_codes = codes_dict.get("mel") - if not isinstance(mel_codes, torch.Tensor) or mel_codes.numel() == 0: - logger.warning("Talker output %d has empty mel_codes", i) - continue - mel_codes = mel_codes.to(torch.long) - - hs_dict = mm.get("hidden_states", {}) - latent = hs_dict.get("latent") - if not isinstance(latent, torch.Tensor) or latent.numel() == 0: - logger.warning("Talker output %d has empty latent", i) - continue - - meta = mm.get("meta", {}) - if not isinstance(meta, dict): - meta = {} - - additional_information = _build_s2mel_additional_information(mel_codes, latent, meta, context="talker2s2mel") - s2mel_inputs.append( - OmniTokensPrompt( - prompt_token_ids=[0], - multi_modal_data=None, - mm_processor_kwargs=None, - additional_information=additional_information, - ) - ) - - return s2mel_inputs - - def talker2s2mel_token_only( source_outputs: list[Any], prompt: Any = None, diff --git a/vllm_omni/model_executor/stage_input_processors/mimo_audio.py b/vllm_omni/model_executor/stage_input_processors/mimo_audio.py index 7c0db4131..3a1a4441f 100644 --- a/vllm_omni/model_executor/stage_input_processors/mimo_audio.py +++ b/vllm_omni/model_executor/stage_input_processors/mimo_audio.py @@ -1,7 +1,6 @@ from typing import Any import torch -from vllm.inputs import TextPrompt from vllm.logger import init_logger from vllm_omni.data_entry_keys import ( @@ -10,7 +9,6 @@ from vllm_omni.data_entry_keys import ( OmniPayload, OmniPayloadStruct, ) -from vllm_omni.inputs.data import OmniTokensPrompt from vllm_omni.model_executor.models.mimo_audio.config_mimo_audio import TALKER_CODEC_PAD_TOKEN_ID logger = init_logger(__name__) @@ -230,88 +228,6 @@ def llm2code2wav_async_chunk( ) -def llm2code2wav( - source_outputs: list[Any], - prompt: OmniTokensPrompt | TextPrompt | None = None, - requires_multimodal_data: bool = False, -) -> list[OmniTokensPrompt]: - """ - Process talker outputs to create code2wav inputs. - - Workflow: - 1. Extract talker's codec code outputs (8-layer RVQ codes) - 2. Flatten codes for code2wav input - 3. Package for code2wav stage - - Args: - prompt: Original prompt data - requires_multimodal_data: Whether multimodal data is required - - Returns: - List of OmniTokensPrompt for code2wav stage - """ - talker_outputs = source_outputs - code2wav_inputs = [] - - # Process each talker output - for i, talker_output in enumerate(talker_outputs): - output = talker_output.outputs[0] - - # Extract codec codes from talker output - # Expected shape: [8, seq_len] (8-layer RVQ codes) - mm = output.multimodal_output - mm_codes = mm.get("codes", {}) - mm_hs = mm.get("hidden_states", {}) - if "audio" in mm_codes: - codec_codes = mm_codes["audio"].to(torch.long) # [seq_batch_size, 1, 8, 4] - is_all_zero = (codec_codes == 0).all(dim=(1, 2, 3)) - non_zero_indices = (~is_all_zero).nonzero(as_tuple=True)[0] - if len(non_zero_indices) == 0: - # All codec codes are zero - skip this request with a warning - request_id = getattr(talker_output, "request_id", f"unknown_{i}") - logger.warning( - "Skipping request %s: all codec codes are zero (empty output from Stage-0). " - "This may indicate the model failed to generate valid audio codes.", - request_id, - ) - else: - if len(non_zero_indices) < codec_codes.shape[0]: - codec_codes = codec_codes[non_zero_indices] - elif "output" in mm_hs and "audio" not in mm_codes: - codec_codes = torch.zeros(1, 1, 8, 4, dtype=torch.long) - else: - raise ValueError(f"Invalid multimodal_output: {output.multimodal_output}") - - pad_vec = torch.tensor([TALKER_CODEC_PAD_TOKEN_ID] * 4) - - code_final = prepend_and_flatten_colmajor(codec_codes, pad_vec) - code_final = code_final.tolist() - - # Guard against flattened sequences longer than code2wav's max_model_len. - # Without this, add_request raises ``could not broadcast input array - # from shape (N,) into shape (max_model_len,)`` and kills the engine - # core (see issue #2683). Mirrors the offline end2end.py safeguard. - if len(code_final) > MAX_CODE2WAV_TOKENS: - request_id = getattr(talker_output, "request_id", f"unknown_{i}") - logger.warning( - "Request %s: code_final len=%d > MAX_CODE2WAV_TOKENS=%d, truncating.", - request_id, - len(code_final), - MAX_CODE2WAV_TOKENS, - ) - code_final = code_final[:MAX_CODE2WAV_TOKENS] - - code2wav_inputs.append( - OmniTokensPrompt( - prompt_token_ids=code_final, - multi_modal_data=None, - mm_processor_kwargs=None, - ) - ) - - return code2wav_inputs - - # ============================================================================ # Worker-connector data plane (non-async-chunk path). # AR runner's `flatten_payload` converts the model emit @@ -329,8 +245,9 @@ _FULL_PAYLOAD_REPLACE_KEYS: frozenset[str] = frozenset() def _filter_zero_codec_rows(codec_codes: torch.Tensor) -> torch.Tensor: """Drop zero-padded codec rows from a 4-D `[N, 1, 8, 4]` tensor. - Mirrors the zero-row filter in the orchestrator-path `llm2code2wav` - body (see this file's ``llm2code2wav`` around line 224). + Shared by the sync placeholder builder (``llm2code2wav_token_only``) and + the full-payload producer (``llm2code2wav_full_payload``) so both size the + downstream codec sequence off the same non-zero frames. """ if codec_codes.ndim != 4 or codec_codes.numel() == 0: return codec_codes diff --git a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py index d624b4a49..6152bb82b 100644 --- a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py +++ b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py @@ -10,7 +10,6 @@ from typing import Any import torch from vllm.inputs import TextPrompt -from vllm.platforms import current_platform from vllm_omni.data_entry_keys import ( CodesStruct, @@ -25,9 +24,7 @@ from vllm_omni.data_entry_keys import ( from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.inputs.data import OmniTokensPrompt from vllm_omni.model_executor.stage_input_processors.tts_utils import ( - extract_language_from_prompt, extract_language_from_request, - extract_speaker_from_prompt, extract_speaker_from_request, ) @@ -569,25 +566,31 @@ def thinker2talker_full_payload( output_token_ids = _ensure_list(getattr(request, "output_token_ids", []) or []) all_token_ids = list(prompt_token_ids) + list(output_token_ids) - # Trim the trailing stop-token row from the accumulated thinker output. - # The accumulator captures one hidden-state row per executed thinker - # forward (prefill + every decode step including the one that emitted - # the stop_token), so for a finished request thinker_emb has exactly one - # row more than the rows the talker should consume. async_chunk's - # chunk-0 path naturally captures only the prefill / non-stop portion, - # which is why the [async_chunk] parametrization passes while [default] - # over-generates one codec frame on short outputs (e.g. - # test_one_word_prompt_001[default]: audio extends "London" with - # spurious phonemes). - if isinstance(thinker_emb, torch.Tensor) and thinker_emb.shape[0] > 0: + # Drop the terminal stop-token row only when more than one row was + # accumulated; trimming a single row would ship 0 conditioning tensors + # while ids still has tokens and break talker prefill alignment. + if isinstance(thinker_emb, torch.Tensor) and thinker_emb.shape[0] > 1: thinker_emb_prefill = thinker_emb[:-1] else: thinker_emb_prefill = thinker_emb - if isinstance(thinker_hid, torch.Tensor) and thinker_hid.shape[0] > 0: + if isinstance(thinker_hid, torch.Tensor) and thinker_hid.shape[0] > 1: thinker_hid_prefill = thinker_hid[:-1] else: thinker_hid_prefill = thinker_hid + emb_rows = int(thinker_emb_prefill.shape[0]) if isinstance(thinker_emb_prefill, torch.Tensor) else 0 + hid_rows = int(thinker_hid_prefill.shape[0]) if isinstance(thinker_hid_prefill, torch.Tensor) else 0 + if len(all_token_ids) > 0 and (emb_rows == 0 or hid_rows == 0): + logger.warning( + "thinker2talker_full_payload: empty thinker conditioning for req=%s " + "(ids_len=%s embed_rows=%s hidden_rows=%s); withholding payload.", + rid, + len(all_token_ids), + emb_rows, + hid_rows, + ) + return None + payload: OmniPayload = { "embed": { "prefill": thinker_emb_prefill.detach().cpu(), @@ -599,7 +602,6 @@ def thinker2talker_full_payload( "ids": {"all": list(all_token_ids), "prompt": list(prompt_token_ids)}, "meta": {"finished": torch.tensor(True, dtype=torch.bool)}, } - payload["next_stage_prompt_len"] = _compute_talker_prompt_ids_length(payload, device="cpu") speaker = extract_speaker_from_request(request) if speaker is not None: payload["speaker"] = speaker @@ -609,154 +611,34 @@ def thinker2talker_full_payload( return payload -def thinker2talker( - source_outputs: list[Any], - prompt: OmniTokensPrompt | TextPrompt | None = None, - requires_multimodal_data: bool = False, - streaming_context: Any | None = None, -) -> list[OmniTokensPrompt]: - """ - Process thinker outputs to create talker inputs. - - Workflow: - 1. Extract thinker's text generation outputs (token IDs + hidden states) - 2. Split hidden states into: prompt embeddings + generated embeddings - 3. Package for talker with additional information - - In PD disaggregation mode, merges prefill-stage prompt embeddings with - decode-stage generated embeddings before handing off to the talker. - - Args: - prompt: Original prompt data - requires_multimodal_data: Whether multimodal data is required - - Returns: - List of OmniTokensPrompt for talker stage - """ - thinker_outputs = source_outputs - talker_inputs: list[OmniTokensPrompt] = [] - - device = torch.device(current_platform.device_type) - - # Process each thinker output - for i, thinker_output in enumerate(thinker_outputs): - output = thinker_output.outputs[0] - req_id = str(getattr(thinker_output, "request_id", f"idx-{i}")) - prompt_token_ids = _ensure_list(thinker_output.prompt_token_ids) - output_ids = _ensure_list(output.cumulative_token_ids) - is_streaming_session = bool(getattr(streaming_context, "enabled", False)) - if is_streaming_session: - prompt_token_ids, output_ids = _get_streaming_talker_tokens( - req_id, - prompt_token_ids, - output_ids, - getattr(streaming_context, "new_prompt_len_snapshot", None), - streaming_context, - clear_state=bool(getattr(thinker_output, "finished", False)), - ) - thinker_sequences = prompt_token_ids + output_ids - thinker_input_ids = prompt_token_ids - new_seq_length = len(prompt_token_ids + output_ids) - 1 - thinker_mm_raw = getattr(output, "multimodal_output", None) - if not isinstance(thinker_mm_raw, Mapping): - logger.debug("thinker2talker: skip req=%s due to empty multimodal_output", req_id) - continue - thinker_mm: OmniPayload = thinker_mm_raw - mm_hs = thinker_mm.get("hidden_states", {}) - mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, Mapping) else {} - emb_layer = _layer_tensor(mm_layers, _EMBED_LAYER_KEY) - hid_layer = _layer_tensor(mm_layers, _HIDDEN_LAYER_KEY) - if emb_layer is None or hid_layer is None: - logger.debug("thinker2talker: skip req=%s due to missing hidden-state layers", req_id) - continue - thinker_emb = emb_layer.detach().to(device=device, dtype=torch.float)[-new_seq_length:] - thinker_hid = hid_layer.detach().to(device=device, dtype=torch.float)[-new_seq_length:] - - prefill_mm: dict[str, Any] | None = None - prefill_mm = _get_prefill_multimodal_output(req_id, streaming_context) - - if prefill_mm is not None: - expected_total = len(prompt_token_ids) + len(output_ids) - try: - thinker_emb, thinker_hid = _merge_pd_embeddings( - thinker_emb, thinker_hid, prefill_mm, device, expected_total=expected_total - ) - except Exception as exc: - logger.warning("[PD] Could not merge prefill embeddings: %s", exc) - - payload = OmniPayloadStruct( - embed=EmbeddingsStruct( - prefill=thinker_emb, - tts_bos=_resolve_tts_token_embedding( - "tts_bos", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device - ), - tts_eos=_resolve_tts_token_embedding( - "tts_eos", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device - ), - tts_pad=_resolve_tts_token_embedding( - "tts_pad", thinker_mm=thinker_mm, prefill_mm=prefill_mm, device=device - ), - ), - hidden_states=HiddenStatesStruct(output=thinker_hid), - ids=IdsStruct(all=thinker_sequences, prompt=thinker_input_ids), - speaker=extract_speaker_from_prompt(prompt, index=i), - language=extract_language_from_prompt(prompt, index=i), - ) - info = to_dict(payload) - prompt_len = _compute_talker_prompt_ids_length(info, device=device) - - talker_inputs.append( - OmniTokensPrompt( - prompt_token_ids=[0] * prompt_len, - additional_information=info, - multi_modal_data=None, - mm_processor_kwargs=None, - ) - ) - - return talker_inputs - - def thinker2talker_token_only( source_outputs: list[Any], prompt: OmniTokensPrompt | TextPrompt | None = None, requires_multimodal_data: bool = False, streaming_context: Any | None = None, ) -> list[OmniTokensPrompt]: - """Non-async-chunk Stage-1 input builder for the connector data plane. + """Orchestrator-side placeholder builder for Stage-1 (Talker) when + ``async_chunk=False``. - The worker connector (Stage-0 ``thinker2talker_full_payload`` → - ``_sync_local_stage_payloads``) supplies the bulk talker conditioning - tensors (embed / hidden_states / ids) via ``model_intermediate_buffer``. - The orchestrator only needs to ship a placeholder prefill prompt of the - correct length so the scheduler can allocate KV-cache slots. + After the communication-layer refactor, this function only allocates a + placeholder ``prompt_token_ids`` of the correct length so the scheduler can + reserve KV-cache slots. It does **not** forward bulk tensors or voice + metadata. - Small per-request voice metadata (``speaker`` / ``language``) is forwarded - here from the user prompt so the worker's line-408 buffer seed picks it - up. The connector-side ``extract_speaker_from_request`` reads the - strongly-typed ``request.additional_information.entries["speaker"]`` which - currently does not always round-trip the user-supplied voice; until that - plumbing is normalized, providing the small fields directly preserves - voice selection (regression discovered on Buildkite 9668: - ``test_speaker_002[default]`` lost the preset voice). + Bulk talker conditioning (embed / hidden_states / ids) and per-request + voice metadata (``speaker`` / ``language``) are packed by Stage-0 + ``thinker2talker_full_payload`` via ``extract_speaker_from_request`` / + ``extract_language_from_request``, sent over the connector, and merged into + the Talker worker's ``model_intermediate_buffer`` on recv. The orchestrator + path therefore sets ``additional_information=None``. + + ``prompt`` / ``requires_multimodal_data`` are kept for call-site signature + compatibility with other orchestrator input processors; they are unused. """ talker_inputs: list[OmniTokensPrompt] = [] for i, thinker_output in enumerate(source_outputs): output = thinker_output.outputs[0] req_id = str(getattr(thinker_output, "request_id", f"idx-{i}")) - # Skip-on-missing parity with thinker2talker_full_payload: if the - # connector builder would drop this request (no MM dict or missing - # hidden-state layers), do the same here so the worker buffer - # presence agrees with the orchestrator's scheduling decision. - thinker_mm_raw = getattr(output, "multimodal_output", None) - if not isinstance(thinker_mm_raw, Mapping): - logger.debug("thinker2talker_token_only: skip req=%s due to empty multimodal_output", req_id) - continue - mm_hs = thinker_mm_raw.get("hidden_states", {}) - mm_layers = mm_hs.get("layers", {}) if isinstance(mm_hs, Mapping) else {} - if _layer_tensor(mm_layers, _EMBED_LAYER_KEY) is None or _layer_tensor(mm_layers, _HIDDEN_LAYER_KEY) is None: - logger.debug("thinker2talker_token_only: skip req=%s due to missing hidden-state layers", req_id) - continue prompt_token_ids = _ensure_list(thinker_output.prompt_token_ids) output_ids = _ensure_list(output.cumulative_token_ids) is_streaming_session = bool(getattr(streaming_context, "enabled", False)) @@ -773,21 +655,10 @@ def thinker2talker_token_only( thinker_input_ids = prompt_token_ids info_for_len = {"ids": {"all": thinker_sequences, "prompt": thinker_input_ids}} prompt_len = _compute_talker_prompt_ids_length(info_for_len, device="cpu") - - # Forward only small voice metadata; bulk tensors come from the - # connector path via _sync_local_stage_payloads. - small_info: dict[str, Any] = {} - speaker = extract_speaker_from_prompt(prompt, index=i) - if speaker is not None: - small_info["speaker"] = speaker - language = extract_language_from_prompt(prompt, index=i) - if language is not None: - small_info["language"] = language - talker_inputs.append( OmniTokensPrompt( prompt_token_ids=[0] * prompt_len, - additional_information=(small_info if small_info else None), + additional_information=None, multi_modal_data=None, mm_processor_kwargs=None, ) @@ -949,59 +820,5 @@ def talker2code2wav_full_payload( ) return { "codes": {"audio": codec_codes}, - "code_predictor_codes": codec_codes, "meta": {"finished": torch.tensor(True, dtype=torch.bool)}, } - - -def talker2code2wav( - source_outputs: list[Any], - _prompt: OmniTokensPrompt | TextPrompt | None = None, - _requires_multimodal_data: bool = False, - streaming_context: Any | None = None, -) -> list[OmniTokensPrompt]: - """ - Process talker outputs to create code2wav inputs. - - Workflow: - 1. Extract talker's codec code outputs (8-layer RVQ codes) - 2. Flatten codes for code2wav input - 3. Package for code2wav stage - - Args: - Returns: - List of OmniTokensPrompt for code2wav stage - """ - talker_outputs = source_outputs - code2wav_inputs: list[OmniTokensPrompt] = [] - # Process each talker output - for i, talker_output in enumerate(talker_outputs): - output = talker_output.outputs[0] - req_id = str(getattr(talker_output, "request_id", f"idx-{i}")) - cur_seq_len = len(output.cumulative_token_ids) - 1 - seq_len = cur_seq_len - is_streaming_session = bool(getattr(streaming_context, "enabled", False)) - if is_streaming_session: - seq_len = _get_streaming_codec_delta_len(cur_seq_len, req_id, talker_output, streaming_context) - mm_raw = getattr(output, "multimodal_output", None) - if not isinstance(mm_raw, Mapping): - logger.debug("talker2code2wav: skip req=%s due to empty multimodal_output", req_id) - continue - mm: OmniPayload = mm_raw - if "codes" not in mm or not isinstance(mm.get("codes"), dict) or "audio" not in mm["codes"]: - logger.debug("talker2code2wav: skip req=%s due to missing codes.audio", req_id) - continue - # Extract codec codes from talker output - # Expected shape: [8, seq_len] (8-layer RVQ codes) - codec_codes = ( - mm["codes"]["audio"][-seq_len:].to(torch.long).transpose(0, 1).cpu().to(torch.long).reshape(-1).tolist() - ) # 16, seq_len - code2wav_inputs.append( - OmniTokensPrompt( - prompt_token_ids=codec_codes, - multi_modal_data=None, - mm_processor_kwargs=None, - ) - ) - - return code2wav_inputs diff --git a/vllm_omni/model_executor/stage_input_processors/qwen3_tts.py b/vllm_omni/model_executor/stage_input_processors/qwen3_tts.py index 6cebc4c28..597fc4db5 100644 --- a/vllm_omni/model_executor/stage_input_processors/qwen3_tts.py +++ b/vllm_omni/model_executor/stage_input_processors/qwen3_tts.py @@ -38,103 +38,6 @@ def _qwen3_tts_empty_finished_payload(): } -def talker2code2wav( - source_outputs: list[Any], - prompt: Any = None, - _requires_multimodal_data: bool = False, -) -> list[Any]: - """Non-async: collect all talker codes, then pass to code2wav at once.""" - from vllm_omni.inputs.data import OmniTokensPrompt - - talker_outputs = source_outputs - code2wav_inputs: list[OmniTokensPrompt] = [] - for i, talker_output in enumerate(talker_outputs): - if not talker_output.finished: - # Non-async decode should only run once, after talker has - # accumulated the final code sequence. - continue - output = talker_output.outputs[0] - mm = output.multimodal_output - mm_codes = mm.get("codes", {}) - - # audio_codes shape: [num_frames, Q] where Q=num_quantizers (16) - audio_codes = mm_codes["audio"].to(torch.long) - token_ids = output.cumulative_token_ids - - # token_ids provides an upper bound on the newly generated codec span. - # audio_codes may still contain zero-padded / invalid rows, so trim only - # after filtering valid frames instead of trying to align EOS indices. - seq_len = max(len(token_ids) - 1, 0) - # Filter invalid frames: zero-padded (EOS), out-of-range values (e.g. - # stop_token_id=2150 exceeds codebook_size=2048), and negative - # sentinels (e.g. -1 padding). - _CODEBOOK_SIZE = 2048 - valid_mask = ( - (audio_codes >= 0).all(dim=1) & audio_codes.any(dim=1) & (audio_codes.max(dim=1).values < _CODEBOOK_SIZE) - ) - audio_codes = audio_codes[valid_mask] - if seq_len > 0 and audio_codes.ndim == 2 and int(audio_codes.shape[0]) > seq_len: - audio_codes = audio_codes[-seq_len:] - ref_code = mm_codes.get("ref") - ref_code_len = mm.get("meta", {}).get("ref_code_len") - if isinstance(ref_code_len, torch.Tensor): - ref_code_len = int(ref_code_len.reshape(-1)[-1].item()) if ref_code_len.numel() > 0 else 0 - elif ref_code_len is None: - ref_code_len = 0 - else: - ref_code_len = int(ref_code_len) - if isinstance(ref_code, list): - ref_code = ref_code[0] if ref_code else None - if isinstance(ref_code, torch.Tensor) and ref_code.numel() > 0: - ref_code = ref_code.to(torch.long).cpu().contiguous() - if ref_code.ndim == 1: - num_quantizers = int(audio_codes.shape[1]) if audio_codes.ndim == 2 and audio_codes.shape[1] > 0 else 16 - if ref_code.numel() % num_quantizers != 0: - logger.warning( - "Ignoring malformed ref_code with %d elements not divisible by num_quantizers=%d", - ref_code.numel(), - num_quantizers, - ) - ref_code = None - else: - ref_code = ref_code.reshape(-1, num_quantizers) - elif ref_code.ndim != 2: - logger.warning("Ignoring malformed ref_code shape %s", tuple(ref_code.shape)) - ref_code = None - if isinstance(ref_code, torch.Tensor) and ref_code_len > 0 and int(ref_code.shape[0]) > ref_code_len: - logger.warning( - "Trimming ref_code from %d frames to ref_code_len=%d before Code2Wav.", - int(ref_code.shape[0]), - ref_code_len, - ) - ref_code = ref_code[:ref_code_len] - if not isinstance(ref_code, torch.Tensor): - ref_code_len = 0 - else: - ref_code_len = int(ref_code.shape[0]) - audio_codes = torch.cat([ref_code.to(audio_codes.device), audio_codes], dim=0) - else: - ref_code_len = 0 - # Code2Wav expects codebook-major flat: [Q*num_frames] - codec_codes = audio_codes.transpose(0, 1).cpu().reshape(-1).tolist() - additional_information = to_dict( - OmniPayloadStruct( - meta=MetaStruct(left_context_size=ref_code_len) if ref_code_len > 0 else None, - speaker=extract_speaker_from_prompt(prompt, index=i), - language=extract_language_from_prompt(prompt, index=i), - ) - ) - code2wav_inputs.append( - OmniTokensPrompt( - prompt_token_ids=codec_codes, - multi_modal_data=None, - mm_processor_kwargs=None, - additional_information=additional_information if additional_information else None, - ) - ) - return code2wav_inputs - - def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torch.Tensor | None: audio_codes = multimodal_output.get("codes", {}).get("audio") if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: @@ -351,10 +254,7 @@ _NUM_QUANTIZERS_DEFAULT = 16 def _filter_audio_codes_qwen3_tts(audio_codes: torch.Tensor) -> torch.Tensor: - """Filter zero-padded, out-of-range, and negative-padded codec frames. - - Mirrors the orchestrator-path body in `talker2code2wav` above. - """ + """Filter zero-padded, out-of-range, and negative-padded codec frames.""" if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: return audio_codes if audio_codes.ndim != 2: @@ -367,10 +267,8 @@ def _filter_audio_codes_qwen3_tts(audio_codes: torch.Tensor) -> torch.Tensor: def _coerce_ref_code_len(raw) -> int: """Coerce mm["meta"]["ref_code_len"] / pooling_output["meta.ref_code_len"] - raw value (Tensor | int | None) into a non-negative int. Mirrors the - extraction inlined in the legacy ``talker2code2wav`` path; clamps any - negative input to 0 since downstream code treats this as a non-negative - frame count.""" + raw value (Tensor | int | None) into a non-negative int; clamps any + negative input to 0 since downstream code treats this as a frame count.""" if isinstance(raw, torch.Tensor): value = int(raw.reshape(-1)[-1].item()) if raw.numel() > 0 else 0 elif raw is None: @@ -381,7 +279,7 @@ def _coerce_ref_code_len(raw) -> int: def _normalize_ref_code(ref_code, num_quantizers: int, ref_code_len: int): - """Coerce ref_code into a [ref_len, Q] tensor or None. Mirrors orchestrator path.""" + """Coerce ref_code into a [ref_len, Q] tensor or None.""" if isinstance(ref_code, list): ref_code = ref_code[0] if ref_code else None if not isinstance(ref_code, torch.Tensor) or ref_code.numel() == 0: @@ -407,8 +305,7 @@ def talker2code2wav_token_only( Sized to the expected codec token count (codebook-major flat: Q * (ref_frames + audio_frames)). Speaker / language metadata are - extracted from `prompt` and threaded via `additional_information` - (orchestrator-style; same as the legacy `talker2code2wav` builder). + extracted from `prompt` and threaded via `additional_information`. Actual codec ids are delivered via the worker connector payload built by `talker2code2wav_full_payload`. """ @@ -473,8 +370,8 @@ def talker2code2wav_full_payload( Reads accumulated codec from `pooling_output["codes.audio"]` (CONCAT across steps via flatten_payload), latest `pooling_output["codes.ref"]` (prefill-emitted), and latest `pooling_output["meta.ref_code_len"]`. - Replicates the orchestrator-path body of `talker2code2wav` (filter, - crop to seq_len, prepend ref, codebook-major flatten). + Filters invalid frames, crops to seq_len, prepends ref, and flattens + codebook-major for code2wav consumption. """ del transfer_manager rid = getattr(request, "request_id", "?") diff --git a/vllm_omni/model_executor/stage_input_processors/voxtral_tts.py b/vllm_omni/model_executor/stage_input_processors/voxtral_tts.py index d3c1219b7..af2cb0a25 100644 --- a/vllm_omni/model_executor/stage_input_processors/voxtral_tts.py +++ b/vllm_omni/model_executor/stage_input_processors/voxtral_tts.py @@ -2,7 +2,6 @@ from collections.abc import Mapping from typing import Any import torch -from vllm.inputs import TextPrompt from vllm.logger import init_logger from vllm_omni.data_entry_keys import ( @@ -11,7 +10,6 @@ from vllm_omni.data_entry_keys import ( OmniPayload, OmniPayloadStruct, ) -from vllm_omni.inputs.data import OmniTokensPrompt logger = init_logger(__name__) @@ -31,29 +29,6 @@ def _codec_audio_tensors(multimodal_output: OmniPayload | dict[str, Any]) -> lis return None -def generator2tokenizer( - source_outputs: list[Any], - _prompt: OmniTokensPrompt | TextPrompt = None, - _requires_multimodal_data: bool = False, -): - tokenizer_inputs = [] - for generator_output in source_outputs: - output = generator_output.outputs[0] - audio_tensors = _codec_audio_tensors(output.multimodal_output) - if not audio_tensors: - raise ValueError("Missing codes.audio in generator multimodal_output") - audio_tokens = torch.cat(audio_tensors, dim=-1).flatten().detach().cpu().tolist() - tokenizer_inputs.append( - OmniTokensPrompt( - prompt_token_ids=audio_tokens, - multi_modal_data=None, - mm_processor_kwargs=None, - ) - ) - - return tokenizer_inputs - - def _extract_last_frame(multimodal_output: OmniPayload | dict[str, Any]) -> torch.Tensor | None: audio_tensors = _codec_audio_tensors(multimodal_output) if not audio_tensors: diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 59bfbb855..d84404508 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -8,7 +8,7 @@ from __future__ import annotations import gc import threading -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Sequence from contextlib import nullcontext from copy import copy from dataclasses import replace @@ -703,12 +703,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin): if self.omni_prefix_cache is not None and is_last_pp_rank: # If this happens, it generally means the model is not following the correct # interface yet and is therefore currently not compatible with prefix cache. - if multimodal_outputs is not None and not isinstance(multimodal_outputs, Mapping): - logger.warning_once( - "prefix caching expects mm outputs to be a dict, but got %s", - type(multimodal_outputs), - ) - hs_for_cache = hidden_states if self._model_needs_full_prefix_hidden_states() else None # FIX: The .cpu attribute of slot_mapping is stale (not updated by the Triton # _compute_slot_mapping_kernel which only writes to .gpu). We must use .gpu and diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index e922146db..b816a36f7 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -601,9 +601,6 @@ class OmniGPUModelRunner(GPUModelRunner): # Decode additional_information payloads (dictionary) try: if getattr(new_req_data, "additional_information", None) is not None: - logger.warning_once( - "additional_information on request data is deprecated, use model_intermediate_buffer" - ) info_dict = deserialize_additional_information(new_req_data.additional_information) if info_dict: self.model_intermediate_buffer[req_id] = info_dict @@ -1471,15 +1468,9 @@ class OmniGPUModelRunner(GPUModelRunner): for new_req in scheduler_output.scheduled_new_reqs: payload_info = getattr(new_req, "additional_information", None) if isinstance(payload_info, dict): - logger.warning_once( - "additional_information on request data is deprecated, use model_intermediate_buffer" - ) self._update_intermediate_buffer(new_req.req_id, payload_info) if hasattr(scheduler_output.scheduled_cached_reqs, "additional_information"): - logger.warning_once( - "additional_information on scheduled_cached_reqs is deprecated, use model_intermediate_buffer" - ) cached_infos = getattr(scheduler_output.scheduled_cached_reqs, "additional_information", {}) if isinstance(cached_infos, dict): for req_id, req_infos in cached_infos.items(): @@ -1707,7 +1698,7 @@ class OmniGPUModelRunner(GPUModelRunner): self.text_step.gpu[dst].copy_(text_step) for req_id_b, update_dict_b in zip(req_ids_b, updates, strict=True): - self._merge_additional_information_update(req_id_b, update_dict_b) + self._update_intermediate_buffer(req_id_b, update_dict_b) decode_req_ids.extend(req_ids_b) decode_start_offsets.extend(start_offsets_b) @@ -1762,7 +1753,7 @@ class OmniGPUModelRunner(GPUModelRunner): decode_start_offsets.append(s) # TODO(Peiqi): the merge stage could move out from the critical path - self._merge_additional_information_update(req_id, update_dict) + self._update_intermediate_buffer(req_id, update_dict) # update the inputs_embeds and input_ids seg_len = min(span_len, req_embeds.shape[0]) @@ -1912,7 +1903,7 @@ class OmniGPUModelRunner(GPUModelRunner): inputs_embeds[start_offset : start_offset + 1] = req_embeds[idx : idx + 1] if code_predictor_codes is not None: update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}} - self._merge_additional_information_update(req_id, update_dict) + self._update_intermediate_buffer(req_id, update_dict) def _model_forward( self, @@ -1986,10 +1977,6 @@ class OmniGPUModelRunner(GPUModelRunner): # Backward compatible: mirror to old setattr location setattr(req_state, "additional_information_cpu", existing) - def _merge_additional_information_update(self, req_id, upd): - logger.warning_once("_merge_additional_information_update is deprecated, use _update_intermediate_buffer") - return self._update_intermediate_buffer(req_id, upd) - def _update_streaming_input_additional_info(self, req_id): # For streaming input prefill case only. Set num processed tokens = 0 for new segment input cached_additional_info = self.model_intermediate_buffer.get(req_id, {}) diff --git a/vllm_omni/worker/omni_connector_model_runner_mixin.py b/vllm_omni/worker/omni_connector_model_runner_mixin.py index c1421a231..b173f81ac 100644 --- a/vllm_omni/worker/omni_connector_model_runner_mixin.py +++ b/vllm_omni/worker/omni_connector_model_runner_mixin.py @@ -919,7 +919,7 @@ class OmniConnectorModelRunnerMixin: if not (finished_req_ids & pending_req_ids): return - logger.info( + logger.debug( "[Stage-%s] flush_full_payload_outputs: finished_req_ids=%s, pending=%s", self._stage_id, finished_req_ids, From cbd3939c3d5099a805397f0cc29a41aa3e9c3235 Mon Sep 17 00:00:00 2001 From: amy-why-3459 Date: Fri, 10 Jul 2026 18:02:17 +0800 Subject: [PATCH 060/101] [BugFix] Fix CI failures caused by stale test expectations after recent stage-payload / deploy-config changes. (#5015) Signed-off-by: amy-why-3459 --- tests/e2e/offline_inference/test_qwen3_tts_customvoice.py | 2 -- .../stage_input_processors/test_qwen3_omni_streaming_helpers.py | 1 - 2 files changed, 3 deletions(-) diff --git a/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py b/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py index 416a5009c..4fa2d4b6f 100644 --- a/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py +++ b/tests/e2e/offline_inference/test_qwen3_tts_customvoice.py @@ -34,12 +34,10 @@ def get_cuda_graph_config(): "stages": { 0: { "max_num_seqs": 1, - "gpu_memory_utilization": 0.2, "enforce_eager": True, "async_scheduling": False, }, 1: { - "gpu_memory_utilization": 0.2, "enforce_eager": True, "async_scheduling": False, }, 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 afc71c476..811620608 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 @@ -247,7 +247,6 @@ def test_thinker2talker_full_payload_packs_complete_tensors() -> None: assert payload["ids"]["all"] == [151644, 872, 3] assert payload["embed"]["prefill"].device.type == "cpu" assert payload["hidden_states"]["output"].device.type == "cpu" - assert payload["meta"]["next_stage_prompt_len"] > 0 assert payload["embed"]["prefill"].shape[0] == 2 assert payload["hidden_states"]["output"].shape[0] == 2 From fe478a95ab39e80085da06e3401c3ed471898a57 Mon Sep 17 00:00:00 2001 From: NumberWan Date: Sat, 11 Jul 2026 01:39:07 +0800 Subject: [PATCH 061/101] [BugFix] Restore vllm_c IR op priority and torch.nn.RMSNorm for Qwen-Image (#4964) (#5009) Signed-off-by: NumberWan --- tests/platforms/test_cuda_ir_op_priority.py | 38 +++++++++++++++++++ .../qwen_image/qwen_image_transformer.py | 11 +++--- vllm_omni/platforms/cuda/platform.py | 12 +----- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 tests/platforms/test_cuda_ir_op_priority.py diff --git a/tests/platforms/test_cuda_ir_op_priority.py b/tests/platforms/test_cuda_ir_op_priority.py new file mode 100644 index 000000000..c27afb7ee --- /dev/null +++ b/tests/platforms/test_cuda_ir_op_priority.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest +from vllm.config import CompilationConfig, CompilationMode, DeviceConfig, VllmConfig + +from vllm_omni.platforms.cuda.platform import CudaOmniPlatform + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _vllm_config(*, backend: str = "inductor", mode: CompilationMode) -> VllmConfig: + return VllmConfig( + device_config=DeviceConfig(device="cpu"), + compilation_config=CompilationConfig(backend=backend, mode=mode), + ) + + +@pytest.mark.parametrize( + "mode", + [CompilationMode.NONE, CompilationMode.VLLM_COMPILE, CompilationMode.STOCK_TORCH_COMPILE], +) +def test_cuda_default_ir_op_priority_prefers_vllm_c_when_inductor_backend(mode: CompilationMode) -> None: + """Regression for #4964: inductor-active configs must not switch to native-only.""" + priority = CudaOmniPlatform.get_default_ir_op_priority(_vllm_config(mode=mode)) + assert priority.rms_norm == ["vllm_c", "native"] + assert priority.fused_add_rms_norm == ["vllm_c", "native"] + + +def test_cuda_default_ir_op_priority_with_oink(monkeypatch: pytest.MonkeyPatch) -> None: + import vllm.envs as envs + + monkeypatch.setattr(envs, "VLLM_USE_OINK_OPS", True) + priority = CudaOmniPlatform.get_default_ir_op_priority( + _vllm_config(mode=CompilationMode.VLLM_COMPILE), + ) + assert priority.rms_norm == ["oink", "vllm_c", "native"] + assert priority.fused_add_rms_norm == ["oink", "vllm_c", "native"] diff --git a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py index 07015a4f4..67e2d0db7 100644 --- a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py +++ b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py @@ -17,7 +17,6 @@ from diffusers.models.embeddings import TimestepEmbedding, Timesteps from diffusers.models.modeling_outputs import Transformer2DModelOutput from diffusers.models.normalization import AdaLayerNormContinuous from vllm.logger import init_logger -from vllm.model_executor.layers.layernorm import RMSNorm from vllm.model_executor.layers.linear import ( ColumnParallelLinear, QKVParallelLinear, @@ -555,8 +554,8 @@ class QwenImageCrossAttention(nn.Module): self.query_num_heads = self.to_qkv.num_heads self.kv_num_heads = self.to_qkv.num_kv_heads - self.norm_q = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() - self.norm_k = RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() + self.norm_q = nn.RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() + self.norm_k = nn.RMSNorm(head_dim, eps=eps) if qk_norm else nn.Identity() self.inner_dim = out_dim if out_dim is not None else head_dim * self.total_num_heads @@ -593,8 +592,8 @@ class QwenImageCrossAttention(nn.Module): prefix=f"{prefix}.to_out.0", ) - self.norm_added_q = RMSNorm(head_dim, eps=eps) - self.norm_added_k = RMSNorm(head_dim, eps=eps) + self.norm_added_q = nn.RMSNorm(head_dim, eps=eps) + self.norm_added_k = nn.RMSNorm(head_dim, eps=eps) self.attn = Attention( num_heads=self.query_num_heads, @@ -1007,7 +1006,7 @@ class QwenImageTransformer2DModel(CachedTransformer): quant_config=quant_config, ) - self.txt_norm = RMSNorm(joint_attention_dim, eps=1e-6) + self.txt_norm = nn.RMSNorm(joint_attention_dim, eps=1e-6) # Entry projections (image/text) are kept full precision — # small sensitive layers at the network boundary (see #2728). diff --git a/vllm_omni/platforms/cuda/platform.py b/vllm_omni/platforms/cuda/platform.py index 81eaa3066..a05bb289a 100644 --- a/vllm_omni/platforms/cuda/platform.py +++ b/vllm_omni/platforms/cuda/platform.py @@ -249,16 +249,8 @@ class CudaOmniPlatform(OmniPlatform, CudaPlatformBase): @classmethod def get_default_ir_op_priority(cls, vllm_config: VllmConfig) -> IrOpPriorityConfig: - """Copied from upstream CudaPlatformBase with inductor-aware logic. - - When inductor is active (compiling) use native as the default; - otherwise prefer vllm_c kernels where available. - """ - from vllm.config.compilation import CompilationMode - - cc = vllm_config.compilation_config - using_inductor = cc.backend == "inductor" and cc.mode != CompilationMode.NONE - default = ["native"] if using_inductor else ["vllm_c", "native"] + """Prefer ``vllm_c`` CUDA kernels over ``native`` for diffusion IR ops.""" + default = ["vllm_c", "native"] # Use oink if enabled for rms_norm # TODO(Laurawly/luka): remove this env var, From ff6f0da271bd1c3658c3e46e4e2cdc4db17219a5 Mon Sep 17 00:00:00 2001 From: Kushanam <42385577+kushanam@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:29:24 -0500 Subject: [PATCH 062/101] [Core] Composable parallel: Phase 1 strategy overlay (TP/DP/stage-replica) (RFC #4084) (#4281) Signed-off-by: Kushan A. Signed-off-by: kushanam Signed-off-by: Kushan A Signed-off-by: kahmadian Co-authored-by: kushanam Co-authored-by: kahmadian --- docs/configuration/composable_parallel.md | 239 +++++++++++ .../offline_inference/qwen2_5_omni/README.md | 21 + .../offline_inference/qwen2_5_omni/end2end.py | 33 ++ .../qwen2_5_omni/strategy_stage_replica.yaml | 22 + .../qwen2_5_omni/strategy_tp2.yaml | 19 + .../config/composable_parallel/test_apply.py | 154 +++++++ .../test_factory_integration.py | 141 +++++++ .../composable_parallel/test_spec_types.py | 42 ++ .../test_strategy_loader.py | 97 +++++ .../composable_parallel/test_translator.py | 195 +++++++++ .../test_async_omni_engine_stage_init.py | 6 +- tests/engine/test_strategy_lb_policy.py | 63 +++ .../test_async_omni_diffusion_config.py | 2 +- .../test_resolve_dreamzero_config.py | 2 +- tests/entrypoints/test_serve.py | 109 ++++- tests/entrypoints/test_utils.py | 7 +- tests/test_config_factory.py | 5 +- tests/utils/test_tracking_parser.py | 10 +- .../config/composable_parallel/__init__.py | 104 +++++ .../config/composable_parallel/aggregation.py | 126 ++++++ vllm_omni/config/composable_parallel/apply.py | 264 ++++++++++++ .../config/composable_parallel/routing.py | 138 +++++++ vllm_omni/config/composable_parallel/spec.py | 136 +++++++ .../composable_parallel/strategy_loader.py | 149 +++++++ .../config/composable_parallel/translator.py | 378 ++++++++++++++++++ vllm_omni/config/config_factory.py | 153 ++++++- vllm_omni/engine/arg_utils.py | 3 + vllm_omni/engine/async_omni_engine.py | 55 ++- vllm_omni/entrypoints/cli/serve.py | 23 +- vllm_omni/entrypoints/utils.py | 77 +++- 30 files changed, 2722 insertions(+), 51 deletions(-) create mode 100644 docs/configuration/composable_parallel.md create mode 100644 examples/offline_inference/qwen2_5_omni/strategy_stage_replica.yaml create mode 100644 examples/offline_inference/qwen2_5_omni/strategy_tp2.yaml create mode 100644 tests/config/composable_parallel/test_apply.py create mode 100644 tests/config/composable_parallel/test_factory_integration.py create mode 100644 tests/config/composable_parallel/test_spec_types.py create mode 100644 tests/config/composable_parallel/test_strategy_loader.py create mode 100644 tests/config/composable_parallel/test_translator.py create mode 100644 tests/engine/test_strategy_lb_policy.py create mode 100644 vllm_omni/config/composable_parallel/__init__.py create mode 100644 vllm_omni/config/composable_parallel/aggregation.py create mode 100644 vllm_omni/config/composable_parallel/apply.py create mode 100644 vllm_omni/config/composable_parallel/routing.py create mode 100644 vllm_omni/config/composable_parallel/spec.py create mode 100644 vllm_omni/config/composable_parallel/strategy_loader.py create mode 100644 vllm_omni/config/composable_parallel/translator.py diff --git a/docs/configuration/composable_parallel.md b/docs/configuration/composable_parallel.md new file mode 100644 index 000000000..400fbab38 --- /dev/null +++ b/docs/configuration/composable_parallel.md @@ -0,0 +1,239 @@ +# Composable parallel strategies + +In vLLM-Omni, the `composable_parallel` system layers a declarative *parallel strategy* on top of the existing stage configs. A strategy YAML maps each pipeline stage (by its `model_stage` name) to a stack of axis declarations — one per parallelism dimension — that the engine translates into per-stage parallel sizing (`tensor_parallel_size`, `data_parallel_size`, `pipeline_parallel_size`, expert-parallel toggle, replica count) and, when relevant, an orchestrator-wide load-balancer policy. + +Strategies are intentionally narrow: each one only writes the axes it declares, leaving every other engine arg untouched. This makes a strategy file a small, reusable overlay you can swap independently of the deploy YAML (see `configuration/stage_configs.md`). + +!!! note + Composable parallel is **opt-in**. Passing `--strategy-config ` overlays the derived sizing onto the registry-merged stages *before* any CLI overrides are applied. When `--strategy-config` is omitted, stages remain exactly as their deploy YAML produces them. + +## Top-level schema reference + +A strategy file has a single top-level mapping, `strategies`, keyed by the `model_stage` name of each stage you want to overlay. Each value is a list of axis declarations (see [StrategySpec fields](#strategyspec-fields) below). + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `strategies` | `dict[str, list]` | required | — | Maps `model_stage` name (e.g. `thinker`, `talker`, `code2wav`, `dit`) to a list of axis declarations. The key MUST be the stage name, not the integer `stage_id`. See `vllm_omni/config/composable_parallel/strategy_loader.py`. | + +### StrategySpec fields + +Each entry in a stage's list becomes one `StrategySpec` (see `vllm_omni/config/composable_parallel/spec.py`). The loader fills in `routing` and `aggregation` from per-kind defaults, so the file stays compact. + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `axis` | str | required | — | Mesh-axis kind. One of `tp`, `dp`, `pp`, `ep`, `stage_replica` (wired today) or `sp_ulysses`, `sp_ring`, `cfg`, `vae_pp`, `hsdp`, `stage_pp`, `cp` (reserved; declaring one raises `AxisTranslationError` at translate time). See `MeshAxisKind` in `vllm_omni/config/composable_parallel/spec.py`. | +| `size` | int | required | — | Axis degree. Must be `> 0`. | +| `routing` | str | optional | per-kind default | Stateless routing policy. Accepted only for `dp` / `stage_replica`; other kinds reject it. Values: `random`, `round_robin`, `least_queue`. (`hash` is recognised but currently raises `NotImplementedError`.) See `RouteByStage` in `vllm_omni/config/composable_parallel/routing.py`. | +| `l1_owner` | str | optional | per-kind default | Overrides which layer owns request routing for this axis. `engine` for `tp`/`dp`/`pp`/`ep`, `delegated` for `stage_replica`. Recorded into `shard_extension`. | +| `name` | str | optional | axis kind | Human-readable label for this spec, used in error messages. | + +Two additional `StrategySpec` slots — `layer_hook_specs` and `kernel_specs` — let advanced authors bind layer-walker hook IDs (`LayerHookSpec`) or kernel IDs (`KernelSpec`) onto an axis. These are not exposed by the YAML loader today and are intended for programmatic construction. See `vllm_omni/config/composable_parallel/spec.py` for the dataclasses. + +### CLI flags + +| Flag | Description | +|------|-------------| +| `--strategy-config PATH` | Path to a `strategy.yaml`. Only takes effect on registry-based deploy paths (`--deploy-config` or the bundled default deploy YAML). Its derived sizing is overlaid onto the merged stages *before* CLI overrides. | +| `--stage-configs-path PATH` | **Legacy.** Loads the old `stage_args` YAML schema for un-migrated models. Passing `--strategy-config` together with this path is silently inapplicable — vLLM-Omni emits a warning and the strategy is ignored (see `vllm_omni/entrypoints/utils.py`, lines 372–380). | +| `--omni-lb-policy POLICY` | Orchestrator-wide load-balancer policy. Strategy-derived from any `stage_replica` axis; an explicit CLI value wins and must match the derived one or `AsyncOmniEngine` raises `Conflicting load-balancer policy` at construction. | + +!!! warning + `--strategy-config` cannot be used with `--stage-configs-path` (the legacy deploy path). If your model has not been migrated to the registry-based pipeline yet, the strategy is silently dropped with a warning. Migrate to a registry-based model or use `--deploy-config` to apply a strategy. + +## Sub-schemas + +### `axis` + +The set of recognised axis kinds is the `MeshAxisKind` enum in `vllm_omni/config/composable_parallel/spec.py`: + +```yaml +# Translatable today (see `_SUPPORTED_KINDS` in translator.py). +axis: tp # tensor parallel — sets tensor_parallel_size +axis: dp # engine data parallel — sets data_parallel_size (vLLM's intra-engine DP LB) +axis: pp # pipeline parallel — sets pipeline_parallel_size +axis: ep # dense expert parallel — sets enable_expert_parallel=True; size must equal tp*dp +axis: stage_replica # omni-coordinator-level replica fan-out — sets num_replicas + omni_lb_policy + +# Reserved (raise AxisTranslationError today; declared in the type system for forward compat). +axis: sp_ulysses +axis: sp_ring +axis: cfg +axis: vae_pp +axis: hsdp +axis: stage_pp +axis: cp +``` + +### `routing` + +The full closed hierarchy lives in `vllm_omni/config/composable_parallel/routing.py` (`Broadcast`, `PartitionByHash`, `ShardSequence`, `ShardSpatial`, `RouteByExpert`, `PipelineMicrobatch`, `DuplicateWithCondTag`, `RouteByStage`). For YAML authors only `RouteByStage` is exposed as a `routing:` string, and only for `dp` / `stage_replica` axes (other axes have fixed routing — `tp`/`ep` broadcast, `pp` microbatch). Recognised policies: + +```yaml +routing: random # uniform-random across replicas/DP ranks +routing: round_robin # cycle through replicas/DP ranks (the default for dp/stage_replica) +routing: least_queue # send to the replica with the smallest backlog +# routing: hash # recognised but raises NotImplementedError; no key-stable balancer yet +``` + +### `aggregation` + +Aggregation is filled in automatically by the loader (`_default_aggregation` in `vllm_omni/config/composable_parallel/strategy_loader.py`) from the axis kind, so users do not author it: + +| Axis kind | Aggregation pattern | +|-----------|---------------------| +| `tp`, `ep` | `TakeRank` (all ranks agree; pick one) | +| `dp`, ep (in some flows) | `Union` (disjoint replica results) | +| `pp` | `StitchPipeline` | +| `stage_replica` | `FanInByStage` | + +Full pattern hierarchy: `TakeRank`, `Union`, `GatherDim`, `AllGather`, `StitchSpatial`, `StitchPipeline`, `Combine`, `FanInByStage` (see `vllm_omni/config/composable_parallel/aggregation.py`). + +## Precedence + +From highest to lowest, when the same axis is touched by multiple layers: + +1. **CLI overrides** (`--stage_N_tensor_parallel_size`, `--stage-overrides`, `--omni-lb-policy`, etc.). A CLI value always wins; a warning is emitted whenever it overrides a strategy-declared axis (see `_reconcile_strategy_with_cli` in `vllm_omni/config/config_factory.py`). +2. **Strategy YAML** (`--strategy-config`). Derived sizing is written onto the merged stages with *conflict-on-explicit* semantics — if the deploy YAML already set a value to something different, application raises `StrategyApplyError`. +3. **Deploy YAML** (`--deploy-config` or the bundled `vllm_omni/deploy/.yaml`). +4. **Parser defaults.** + +After CLI overrides land, the device-layout guard re-runs (`check_device_layout`, called from `_reconcile_strategy_with_cli`) against the *effective* `tp * dp * pp * num_replicas` and the resolved `devices` string, so a `--devices` or `--tensor-parallel-size` that conflicts with the strategy's world size cannot slip past the pre-spawn check. + +## Worked example + +### Example 1 — `strategy_tp2.yaml` + +Shard the `thinker` stage with TP=2 on top of the bundled Qwen2.5-Omni deploy YAML. Routing is fixed (broadcast) for TP, so no `routing:` field is needed. + +```yaml +# examples/offline_inference/qwen2_5_omni/strategy_tp2.yaml +strategies: + thinker: + - axis: tp + size: 2 +``` + +Launch: + +```bash +python3 end2end.py --query-type text --num-prompts 6 \ + --strategy-config strategy_tp2.yaml \ + --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' +``` + +Effective `OmniParallelConfig` per stage after the overlay (see `OmniParallelConfig` in `vllm_omni/config/composable_parallel/translator.py`): + +| Stage (`model_stage`) | `tensor_parallel_size` | `data_parallel_size` | `pipeline_parallel_size` | `stage_replica_size` | `omni_lb_policy` | +|-----------------------|------------------------|----------------------|--------------------------|----------------------|------------------| +| `thinker` | **2** (from strategy) | 1 | 1 | 1 | — | +| `talker` | 1 | 1 | 1 | 1 | — | +| `code2wav` | 1 | 1 | 1 | 1 | — | + +### Example 2 — `strategy_stage_replica.yaml` + +Replicate the `thinker` stage across two independent engines and let omni's StagePool round-robin requests across them. + +```yaml +# examples/offline_inference/qwen2_5_omni/strategy_stage_replica.yaml +strategies: + thinker: + - axis: stage_replica + size: 2 + routing: round_robin +``` + +Launch: + +```bash +python3 end2end.py --query-type text --num-prompts 6 \ + --strategy-config strategy_stage_replica.yaml \ + --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' +``` + +Effective per-stage config and the derived orchestrator knob: + +| Stage (`model_stage`) | `tensor_parallel_size` | `stage_replica_size` | `omni_lb_policy` (derived) | +|-----------------------|------------------------|----------------------|----------------------------| +| `thinker` | 1 | **2** | **`round-robin`** | +| `talker` | 1 | 1 | — | +| `code2wav` | 1 | 1 | — | + +The derived `omni_lb_policy = round-robin` is **not** written into any stage's engine args. Instead it is surfaced on `StrategyApplyResult.omni_lb_policy` and applied once at orchestrator construction by `AsyncOmniEngine._apply_strategy_lb_policy` (see `vllm_omni/engine/async_omni_engine.py`, lines 1107–1134). If you also pass `--omni-lb-policy round-robin` the values must match; passing a different value raises `Conflicting load-balancer policy`. + +## Per-field deep-dive + +### `name` + +Default: the axis kind (e.g. `"tp"`). + +Human-readable label that appears in `AxisTranslationError` and `StrategyApplyError` messages. Set it to disambiguate two stages declaring the same axis kind when scanning logs. + +### `mesh_axis` (`axis` + `size`) + +Default: no default — both fields are required. + +`axis` chooses the parallelism dimension; `size` is its degree. Five kinds are wired end-to-end today (`tp`, `dp`, `pp`, `ep`, `stage_replica`); the remaining kinds in `MeshAxisKind` are reserved and raise `AxisTranslationError` at translate time so a typo cannot silently become a no-op (see `_SUPPORTED_KINDS` in `vllm_omni/config/composable_parallel/translator.py`). For `ep`, the size MUST equal `tensor_parallel_size * data_parallel_size`. + +### `routing` + +Default: per-kind — `RouteByStage(round_robin)` for `dp` and `stage_replica`; `PipelineMicrobatch()` for `pp`; `Broadcast()` for `tp`/`ep` and any other kind. + +User-facing string only on `dp` and `stage_replica` axes. The four recognised values are `random`, `round_robin`, `least_queue`, `hash`; `hash` is rejected today with `NotImplementedError`. See `RouteByStage` in `vllm_omni/config/composable_parallel/routing.py`. + +### `l1_owner` + +Default: per-kind — `engine` for `tp`/`dp`/`pp`/`ep`, `delegated` for `stage_replica`. + +Overrides which layer owns request routing along this axis. `engine` means vLLM realizes routing intra-engine; `delegated` means the omni coordinator owns routing across independent replicas. Set this only when overriding the per-kind default in `_DEFAULT_L1_OWNER` (`vllm_omni/config/composable_parallel/translator.py`); invalid values fail fast in `_resolve_l1_owner`. + +### `stage_replica` and `omni_lb_policy` + +`stage_replica` is *not* a vLLM world dimension; it spins up `size` independent engine replicas of one stage, coordinated by the omni `StagePool` load balancer. The axis writes both: + +- The stage's `num_replicas` (a per-stage deploy knob). +- The pipeline-wide `omni_lb_policy` string (an orchestrator-level knob). + +Because `omni_lb_policy` is read once at `AsyncOmniEngine` construction — not from per-stage engine args — only one value is allowed across the whole pipeline. If two stages declare conflicting policies, application raises (see `apply_strategy_specs` in `vllm_omni/config/composable_parallel/apply.py`, lines 253–260). To set this knob manually instead, use `--omni-lb-policy` on the CLI; see [the model stage docs](stage_configs.md) for how stage replicas plug into the orchestrator at runtime. + +### `model_stage` keys + +Default: no default — required for every entry under `strategies:`. + +Strategy keys MUST be `model_stage` *names* (strings), the same labels stage configs use for `engine_args.model_stage` (see [`model_stage` in `stage_configs.md`](stage_configs.md#engine_argsmodel_stage)). Integer `stage_id` values are not accepted; the resolver matches on `model_stage` exactly (`_resolve_stage` in `vllm_omni/config/composable_parallel/apply.py`). + +## Common pitfalls + +!!! warning "Using `stage_id` integers as keys" + Strategy keys are `model_stage` names (str), not integer stage IDs. A file that uses an integer index will raise at apply time: + + ```text + StrategyApplyError: strategy role model_stage=0 did not match any stage; + available stages: 'thinker'(id=0), 'talker'(id=1), 'code2wav'(id=2) + ``` + + Use the `model_stage` name (e.g. `thinker`) instead. + +!!! warning "Mixing CLI overrides with a strategy on the same axis" + A strategy is meant to be the single writer for the axes it declares. If a CLI override (`--stage_0_tensor_parallel_size`, `--stage-overrides '{"0": {...}}'`) sets the same axis, the **CLI value wins** and a warning is emitted from `_reconcile_strategy_with_cli`. The device-layout guard then re-runs against the effective layout, so a CLI override that breaks `tp * dp * pp * num_replicas` will still fail fast at config time. + +!!! warning "Passing `--strategy-config` against the legacy `--stage-configs-path`" + Composable parallel only applies on the registry-based deploy path. If a model still resolves through the legacy `stage_args` YAML (loaded via `--stage-configs-path`), `--strategy-config` is silently inapplicable and emits: + + ```text + --strategy-config () was provided but model '' resolves via the + legacy stage_configs YAML path, which does not support composable-parallel + strategies; the strategy is ignored. Use a registry-based model to apply it. + ``` + + Migrate the model to the new deploy schema (see `configuration/stage_configs.md`) before applying a strategy. + +!!! warning "Conflicting `omni_lb_policy` across stages" + `omni_lb_policy` is a single pipeline-wide knob. If two `stage_replica` axes in different stages derive different policies, `apply_strategy_specs` raises: + + ```text + StrategyApplyError: role 'talker' derives omni_lb_policy='least-queue-length' + but role 'thinker' already derived 'round-robin'. The load-balancer policy is + pipeline-wide; only one value is allowed. + ``` + + Make the policies match, drop one, or supply the value explicitly via `--omni-lb-policy`. diff --git a/examples/offline_inference/qwen2_5_omni/README.md b/examples/offline_inference/qwen2_5_omni/README.md index e2eae8a96..292045033 100644 --- a/examples/offline_inference/qwen2_5_omni/README.md +++ b/examples/offline_inference/qwen2_5_omni/README.md @@ -60,3 +60,24 @@ If media file paths are not provided, the script will use default assets. Suppor - `mixed_modalities`: Audio + image + video - `use_audio_in_video`: Extract audio from video - `text`: Text-only query + +### Composable parallelism (strategy configs) + +You can shard or replicate a stage with a small composable-parallel +`strategy.yaml` overlaid onto the **bundled default deploy config** -- no bespoke +deploy YAML required. Supply the strategy with `--strategy-config` and any matching +device layout via `--stage-overrides` (explicit kwargs forwarded to `Omni`). + +Both examples below require >= 3 GPUs (the thinker uses 2, talker + code2wav share a third): + +```bash +# Tensor-parallel the thinker (TP=2) +python end2end.py --query-type text --num-prompts 6 \ + --strategy-config strategy_tp2.yaml \ + --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' + +# Replicate the thinker across 2 engines (round-robin load balancing) +python end2end.py --query-type text --num-prompts 6 \ + --strategy-config strategy_stage_replica.yaml \ + --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' +``` diff --git a/examples/offline_inference/qwen2_5_omni/end2end.py b/examples/offline_inference/qwen2_5_omni/end2end.py index 8290d27b5..d8769b5f7 100644 --- a/examples/offline_inference/qwen2_5_omni/end2end.py +++ b/examples/offline_inference/qwen2_5_omni/end2end.py @@ -553,6 +553,39 @@ def parse_args(): default=False, help="Use py_generator mode. The returned type of Omni.generate() is a Python Generator object.", ) + parser.add_argument( + "--deploy-config", + type=str, + default=None, + help="Optional explicit deploy YAML (otherwise the bundled default is used).", + ) + parser.add_argument( + "--strategy-config", + type=str, + default=None, + help=( + "Optional composable-parallel strategy.yaml mapping role -> parallel axes " + "(e.g. tp / stage_replica). Overlaid onto the merged stage configs." + ), + ) + parser.add_argument( + "--stage-overrides", + type=str, + default=None, + help=( + "Optional JSON of per-stage overrides applied on top of the default " + 'deploy config, e.g. \'{"0": {"devices": "0,1"}}\' to give the ' + "thinker a 2-GPU pool. Lets you run a strategy on the bundled default " + "deploy config without writing a bespoke deploy YAML." + ), + ) + parser.add_argument( + "--omni-lb-policy", + type=str, + default=None, + choices=["random", "round-robin", "least-queue-length"], + help="StagePool load-balancer policy for replicated stages (orchestrator-level knob).", + ) return parser.parse_args() diff --git a/examples/offline_inference/qwen2_5_omni/strategy_stage_replica.yaml b/examples/offline_inference/qwen2_5_omni/strategy_stage_replica.yaml new file mode 100644 index 000000000..f0e2d9bf5 --- /dev/null +++ b/examples/offline_inference/qwen2_5_omni/strategy_stage_replica.yaml @@ -0,0 +1,22 @@ +# Composable-parallel strategy for Qwen2.5-Omni. +# +# Replicate the thinker stage across 2 independent engines and let the omni +# StagePool balance requests round-robin across them. The thinker's `devices` +# entry acts as a per-replica template, so omni expands it to one GPU per replica. +# The `routing: round_robin` below auto-derives the StagePool load-balancer policy, +# so --omni-lb-policy is not needed. +# +# Rather than hand-write a bespoke deploy YAML, run on the bundled default deploy +# config and pass the matching 2-GPU device template via --stage-overrides (explicit +# kwargs): the thinker gets a two-GPU pool while talker + code2wav share a third +# card. Requires >= 3 GPUs. +# +# Usage: +# python3 end2end.py --query-type text --num-prompts 6 \ +# --strategy-config strategy_stage_replica.yaml \ +# --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' +strategies: + thinker: + - axis: stage_replica + size: 2 + routing: round_robin diff --git a/examples/offline_inference/qwen2_5_omni/strategy_tp2.yaml b/examples/offline_inference/qwen2_5_omni/strategy_tp2.yaml new file mode 100644 index 000000000..02d773952 --- /dev/null +++ b/examples/offline_inference/qwen2_5_omni/strategy_tp2.yaml @@ -0,0 +1,19 @@ +# Composable-parallel strategy for Qwen2.5-Omni: tensor-parallel the thinker. +# +# Shard the thinker stage across 2 GPUs with tensor parallelism (TP=2). TP routing +# is fixed (broadcast), so no `routing` / load-balancer policy is involved -- this +# is the simplest axis and a good starting point for the mental model. +# +# The strategy sets tensor_parallel_size=2 on the thinker. Rather than hand-write a +# bespoke deploy YAML, run on the bundled default deploy config and pass a matching +# 2-GPU device layout via --stage-overrides (explicit kwargs), so the thinker gets a +# two-GPU pool while talker + code2wav share a third card. Requires >= 3 GPUs. +# +# Usage: +# python3 end2end.py --query-type text --num-prompts 6 \ +# --strategy-config strategy_tp2.yaml \ +# --stage-overrides '{"0": {"devices": "0,1"}, "1": {"devices": "2"}, "2": {"devices": "2"}}' +strategies: + thinker: + - axis: tp + size: 2 diff --git a/tests/config/composable_parallel/test_apply.py b/tests/config/composable_parallel/test_apply.py new file mode 100644 index 000000000..044b2cb02 --- /dev/null +++ b/tests/config/composable_parallel/test_apply.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for applying strategy specs onto merged stage configs.""" + +from __future__ import annotations + +import pytest + +from vllm_omni.config.composable_parallel import ( + Broadcast, + FanInByStage, + MeshAxisSpec, + RouteByStage, + StrategyApplyError, + StrategySpec, + TakeRank, + apply_strategy_specs, +) +from vllm_omni.config.stage_config import StageConfig + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _tp(size: int) -> StrategySpec: + return StrategySpec("tp", MeshAxisSpec("tp", size), Broadcast(), TakeRank()) + + +def _stage_replica(size: int, policy: str = "round_robin") -> StrategySpec: + return StrategySpec("stage_replica", MeshAxisSpec("stage_replica", size), RouteByStage(policy), FanInByStage()) + + +def _stage(stage_id: int, model_stage: str, engine_args=None, runtime=None) -> StageConfig: + return StageConfig( + stage_id=stage_id, + model_stage=model_stage, + yaml_engine_args=dict(engine_args or {}), + yaml_runtime=dict(runtime or {"num_replicas": 1}), + ) + + +def _qwen_stages() -> list[StageConfig]: + return [ + _stage(0, "thinker"), + _stage(1, "talker"), + _stage(2, "code2wav"), + ] + + +def test_apply_tp_by_role(): + stages = _qwen_stages() + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert stages[0].yaml_engine_args["tensor_parallel_size"] == 2 + # untouched roles keep their config + assert "tensor_parallel_size" not in stages[1].yaml_engine_args + + +def test_apply_by_model_stage(): + stages = _qwen_stages() + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert stages[0].yaml_engine_args["tensor_parallel_size"] == 2 + + +def test_apply_stage_replica_sets_num_replicas_and_surfaces_lb(): + stages = _qwen_stages() + result = apply_strategy_specs(stages, {"talker": [_stage_replica(2, "round_robin")]}) + assert stages[1].yaml_runtime["num_replicas"] == 2 + assert result.omni_lb_policy == "round-robin" + + +def test_only_declared_axes_are_written(): + stages = _qwen_stages() + # strategy declares only stage_replica -> tp must not be forced. + apply_strategy_specs(stages, {"talker": [_stage_replica(2)]}) + assert "tensor_parallel_size" not in stages[1].yaml_engine_args + + +def test_conflict_on_explicit_tp(): + stages = _qwen_stages() + stages[0].yaml_engine_args["tensor_parallel_size"] = 4 + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + + +def test_equal_explicit_value_is_noop(): + stages = _qwen_stages() + stages[0].yaml_engine_args["tensor_parallel_size"] = 2 + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert stages[0].yaml_engine_args["tensor_parallel_size"] == 2 + + +def test_explicit_none_conflicts_with_derived_value(): + # An explicit YAML ``null`` (``tensor_parallel_size: null``) is a *present* + # value, not a missing key, so a strategy deriving a non-None size must raise + # rather than silently clobber the explicit None. + stages = _qwen_stages() + stages[0].yaml_engine_args["tensor_parallel_size"] = None + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + + +def test_missing_key_is_filled_without_conflict(): + # A genuinely absent key (never set in the YAML) is filled by the strategy + # and must NOT raise — the contrast case to an explicit None. + stages = _qwen_stages() + assert "tensor_parallel_size" not in stages[0].yaml_engine_args + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert stages[0].yaml_engine_args["tensor_parallel_size"] == 2 + + +def test_num_replicas_conflict(): + stages = _qwen_stages() + stages[1].yaml_runtime["num_replicas"] = 3 + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"talker": [_stage_replica(2)]}) + + +def test_device_count_ok_template(): + stages = _qwen_stages() + stages[0].yaml_runtime["devices"] = "0,1" + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert stages[0].yaml_engine_args["tensor_parallel_size"] == 2 + + +def test_device_count_ok_pool(): + # tp=2 -> world=2; 2 replicas -> pool of 4 device ids is valid. + stages = _qwen_stages() + stages[1].yaml_runtime["devices"] = "0,1,2,3" + apply_strategy_specs(stages, {"talker": [_tp(2), _stage_replica(2)]}) + assert stages[1].yaml_runtime["num_replicas"] == 2 + + +def test_device_count_mismatch(): + stages = _qwen_stages() + stages[0].yaml_runtime["devices"] = "0,1,2" + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + + +def test_unknown_role_raises(): + stages = _qwen_stages() + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"nonexistent": [_tp(2)]}) + + +def test_conflicting_lb_policy_across_roles(): + stages = _qwen_stages() + with pytest.raises(StrategyApplyError): + apply_strategy_specs( + stages, + { + "talker": [_stage_replica(2, "round_robin")], + "code2wav": [_stage_replica(2, "least_queue")], + }, + ) diff --git a/tests/config/composable_parallel/test_factory_integration.py b/tests/config/composable_parallel/test_factory_integration.py new file mode 100644 index 000000000..ea685541c --- /dev/null +++ b/tests/config/composable_parallel/test_factory_integration.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Integration tests: strategy overlay on the real merge_pipeline_deploy seam.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from vllm_omni.config.composable_parallel import ( + Broadcast, + FanInByStage, + MeshAxisSpec, + RouteByStage, + StrategyApplyError, + StrategySpec, + TakeRank, + apply_strategy_specs, +) +from vllm_omni.config.config_factory import StageConfigFactory +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES +from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_DEPLOY = Path(__file__).parents[3] / "vllm_omni" / "deploy" / "qwen2_5_omni.yaml" + + +def _tp(size: int) -> StrategySpec: + return StrategySpec("tp", MeshAxisSpec("tp", size), Broadcast(), TakeRank()) + + +def _stage_replica(size: int, policy: str = "round_robin") -> StrategySpec: + return StrategySpec("stage_replica", MeshAxisSpec("stage_replica", size), RouteByStage(policy), FanInByStage()) + + +def _qwen_stages(): + pipeline = OMNI_PIPELINES["qwen2_5_omni"] + deploy = load_deploy_config(_DEPLOY) + return merge_pipeline_deploy(pipeline, deploy) + + +def _stage(stages, role): + return next(s for s in stages if s.model_stage == role) + + +def test_overlay_tp_on_thinker(): + stages = _qwen_stages() + # The bundled deploy pins the thinker to one GPU; a TP=2 strategy needs a + # matching 2-GPU layout (mirrors what a TP2 deploy would declare). + _stage(stages, "thinker").yaml_runtime["devices"] = "0,1" + result = apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + assert _stage(result.stages, "thinker").yaml_engine_args["tensor_parallel_size"] == 2 + + +def test_device_guard_rejects_tp_on_single_gpu_deploy(): + # The bundled deploy pins the thinker to a single GPU, so the pre-spawn + # device check must refuse a TP=2 strategy on it. + stages = _qwen_stages() + with pytest.raises(StrategyApplyError): + apply_strategy_specs(stages, {"thinker": [_tp(2)]}) + + +def test_overlay_stage_replica_on_talker(): + stages = _qwen_stages() + result = apply_strategy_specs(stages, {"talker": [_stage_replica(2, "round_robin")]}) + assert _stage(result.stages, "talker").yaml_runtime["num_replicas"] == 2 + assert result.omni_lb_policy == "round-robin" + + +def test_overlay_mixed_roles(): + stages = _qwen_stages() + _stage(stages, "thinker").yaml_runtime["devices"] = "0,1" + result = apply_strategy_specs( + stages, + { + "thinker": [_tp(2)], + "talker": [_stage_replica(2, "round_robin")], + "code2wav": [_stage_replica(2, "round_robin")], + }, + ) + by_role = {s.model_stage: s for s in result.stages} + assert by_role["thinker"].yaml_engine_args["tensor_parallel_size"] == 2 + assert by_role["talker"].yaml_runtime["num_replicas"] == 2 + assert by_role["code2wav"].yaml_runtime["num_replicas"] == 2 + assert result.omni_lb_policy == "round-robin" + + +def _resolved(stages, role): + """Return a role's fully-resolved (post-CLI) OmegaConf stage config.""" + stage = next(s for s in stages if s.model_stage == role) + return stage.to_omegaconf() + + +def test_device_check_survives_cli_override(): + # Strategy replicates the talker (1-GPU template -> valid at apply time), + # but a CLI --stage_1_devices with 3 ids must NOT slip past the device + # guard: effective world=1, replicas=2 admits only 1 or 2 device ids. + pipeline_cfg = OMNI_PIPELINES["qwen2_5_omni"] + with pytest.raises(StrategyApplyError): + StageConfigFactory._create_from_registry( + "qwen2_5_omni", + pipeline_cfg, + cli_overrides={"stage_1_devices": "0,1,2"}, + strategy_specs={"talker": [_stage_replica(2, "round_robin")]}, + ) + + +def test_cli_overrides_strategy_with_warning(): + # Strategy derives num_replicas=2 for the talker; a CLI override to 3 wins + # (it is the most explicit user action) but must be surfaced loudly rather + # than silently. The resulting layout (1 template device) stays valid. + # + # vLLM's logger sets propagate=False, so attach a handler directly to it + # rather than relying on pytest's caplog (which listens on the root logger). + messages: list[str] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + messages.append(record.getMessage()) + + log = logging.getLogger("vllm_omni.config.config_factory") + handler = _Capture(level=logging.WARNING) + log.addHandler(handler) + try: + pipeline_cfg = OMNI_PIPELINES["qwen2_5_omni"] + stages, _ = StageConfigFactory._create_from_registry( + "qwen2_5_omni", + pipeline_cfg, + cli_overrides={"stage_1_num_replicas": 3}, + strategy_specs={"talker": [_stage_replica(2, "round_robin")]}, + ) + finally: + log.removeHandler(handler) + + # CLI value wins in the resolved config. + assert _resolved(stages, "talker").runtime.num_replicas == 3 + # ...and the override was warned about, naming the conflicting field. + assert any("num_replicas" in m and "overrides the strategy-derived" in m for m in messages) diff --git a/tests/config/composable_parallel/test_spec_types.py b/tests/config/composable_parallel/test_spec_types.py new file mode 100644 index 000000000..90b00cf95 --- /dev/null +++ b/tests/config/composable_parallel/test_spec_types.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for the vendored spec/pattern data types.""" + +from __future__ import annotations + +import pytest + +from vllm_omni.config.composable_parallel.aggregation import AggregationPattern, TakeRank +from vllm_omni.config.composable_parallel.routing import RoutingPattern +from vllm_omni.config.composable_parallel.spec import MeshAxisSpec + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_mesh_axis_rejects_bad_kind(): + with pytest.raises(ValueError): + MeshAxisSpec(kind="not_a_kind", size=2) # type: ignore[arg-type] + + +def test_mesh_axis_rejects_nonpositive_size(): + with pytest.raises(ValueError): + MeshAxisSpec(kind="tp", size=0) + + +def test_routing_pattern_is_closed(): + with pytest.raises(TypeError): + + class RogueRouting(RoutingPattern): # noqa: F811 + pass + + +def test_aggregation_pattern_is_closed(): + with pytest.raises(TypeError): + + class RogueAggregation(AggregationPattern): # noqa: F811 + pass + + +def test_take_rank_type_guard(): + with pytest.raises(TypeError): + TakeRank(rank=True) # bool is not a valid rank diff --git a/tests/config/composable_parallel/test_strategy_loader.py b/tests/config/composable_parallel/test_strategy_loader.py new file mode 100644 index 000000000..01e202d05 --- /dev/null +++ b/tests/config/composable_parallel/test_strategy_loader.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for parsing/loading strategy files.""" + +from __future__ import annotations + +import pytest + +from vllm_omni.config.composable_parallel.routing import Broadcast, RouteByStage +from vllm_omni.config.composable_parallel.strategy_loader import ( + StrategyLoadError, + load_strategy_specs, + parse_strategy_specs, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_parse_basic(): + data = { + "strategies": { + "thinker": [{"axis": "tp", "size": 2}], + "talker": [{"axis": "stage_replica", "size": 2, "routing": "round_robin"}], + } + } + specs = parse_strategy_specs(data) + assert set(specs) == {"thinker", "talker"} + + (tp_spec,) = specs["thinker"] + assert tp_spec.mesh_axis.kind == "tp" + assert tp_spec.mesh_axis.size == 2 + assert isinstance(tp_spec.routing, Broadcast) + + (sr_spec,) = specs["talker"] + assert sr_spec.mesh_axis.kind == "stage_replica" + assert isinstance(sr_spec.routing, RouteByStage) + assert sr_spec.routing.routing_policy == "round_robin" + + +def test_parse_without_strategies_key(): + # A bare role mapping (no top-level "strategies") is also accepted. + specs = parse_strategy_specs({"thinker": [{"axis": "tp", "size": 4}]}) + assert specs["thinker"][0].mesh_axis.size == 4 + + +def test_l1_owner_goes_to_shard_extension(): + specs = parse_strategy_specs({"talker": [{"axis": "stage_replica", "size": 2, "l1_owner": "delegated"}]}) + assert specs["talker"][0].shard_extension["l1_owner"] == "delegated" + + +def test_missing_axis_raises(): + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": [{"size": 2}]}) + + +def test_missing_size_raises(): + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": [{"axis": "tp"}]}) + + +def test_routing_on_non_policy_axis_raises(): + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": [{"axis": "tp", "size": 2, "routing": "random"}]}) + + +def test_bad_size_raises(): + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": [{"axis": "tp", "size": "two"}]}) + + +def test_entries_must_be_list(): + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": {"axis": "tp", "size": 2}}) + + +def test_non_mapping_entry_raises(): + # A list whose elements are not mappings (e.g. a bare string) must raise a + # StrategyLoadError, not an opaque TypeError from dict(entry). + with pytest.raises(StrategyLoadError): + parse_strategy_specs({"thinker": ["tp"]}) + + +def test_load_from_file(tmp_path): + path = tmp_path / "strategy.yaml" + path.write_text( + "strategies:\n" + " thinker:\n" + " - axis: tp\n" + " size: 2\n" + " talker:\n" + " - axis: stage_replica\n" + " size: 2\n" + " routing: least_queue\n" + ) + specs = load_strategy_specs(str(path)) + assert specs["thinker"][0].mesh_axis.size == 2 + assert specs["talker"][0].routing.routing_policy == "least_queue" diff --git a/tests/config/composable_parallel/test_translator.py b/tests/config/composable_parallel/test_translator.py new file mode 100644 index 000000000..b55c10e9e --- /dev/null +++ b/tests/config/composable_parallel/test_translator.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for StrategySpec -> OmniParallelConfig translation.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from vllm_omni.config.composable_parallel import ( + AxisTranslationError, + Broadcast, + FanInByStage, + MeshAxisSpec, + PartitionByHash, + PipelineMicrobatch, + RouteByStage, + StrategySpec, + TakeRank, + Union, + translate_strategy_stack, +) +from vllm_omni.config.composable_parallel.aggregation import StitchPipeline + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _tp(size: int) -> StrategySpec: + return StrategySpec("tp", MeshAxisSpec("tp", size), Broadcast(), TakeRank()) + + +def _dp(size: int, policy: str = "round_robin") -> StrategySpec: + return StrategySpec("dp", MeshAxisSpec("dp", size), RouteByStage(policy), Union()) + + +def _pp(size: int) -> StrategySpec: + return StrategySpec("pp", MeshAxisSpec("pp", size), PipelineMicrobatch(), StitchPipeline()) + + +def _ep(size: int) -> StrategySpec: + return StrategySpec("ep", MeshAxisSpec("ep", size), Broadcast(), Union()) + + +def _stage_replica(size: int, policy: str = "round_robin") -> StrategySpec: + return StrategySpec("stage_replica", MeshAxisSpec("stage_replica", size), RouteByStage(policy), FanInByStage()) + + +def test_tp_only(): + cfg = translate_strategy_stack([_tp(2)]) + assert cfg.tensor_parallel_size == 2 + assert cfg.data_parallel_size == 1 + assert cfg.world_size == 2 + assert cfg.stage_replica_size == 1 + assert cfg.l1_owners == {"tp": "engine"} + + +def test_dp_is_engine_owned(): + cfg = translate_strategy_stack([_dp(2)]) + assert cfg.data_parallel_size == 2 + assert cfg.world_size == 2 + # engine DP does not emit an omni LB policy (vLLM's internal DP LB is used). + assert cfg.omni_lb_policy is None + assert cfg.as_engine_kwargs().get("omni_lb_policy") is None + assert cfg.l1_owners["dp"] == "engine" + + +def test_tp_times_dp_world_size(): + cfg = translate_strategy_stack([_tp(2), _dp(2)]) + assert cfg.world_size == 4 + assert cfg.tensor_parallel_size == 2 + assert cfg.data_parallel_size == 2 + + +def test_stage_replica_is_delegated(): + cfg = translate_strategy_stack([_stage_replica(3, "least_queue")]) + # stage_replica is NOT a world dimension. + assert cfg.world_size == 1 + assert cfg.stage_replica_size == 3 + assert cfg.omni_lb_policy == "least-queue-length" + assert cfg.l1_owners["stage_replica"] == "delegated" + # stage_replica_size is not an engine kwarg; it's a per-stage deploy knob. + assert "stage_replica_size" not in cfg.as_engine_kwargs() + + +def test_stage_replica_lb_policy_only_when_replicated(): + cfg = translate_strategy_stack([_stage_replica(1)]) + assert cfg.stage_replica_size == 1 + assert cfg.as_engine_kwargs().get("omni_lb_policy") is None + + +def test_tp_with_stage_replica(): + cfg = translate_strategy_stack([_tp(2), _stage_replica(2)]) + assert cfg.world_size == 2 # tp only + assert cfg.tensor_parallel_size == 2 + assert cfg.stage_replica_size == 2 + assert cfg.omni_lb_policy == "round-robin" + + +def test_ep_must_match_tp_dp_product(): + cfg = translate_strategy_stack([_tp(2), _ep(2)]) + assert cfg.enable_expert_parallel is True + assert cfg.as_engine_kwargs()["enable_expert_parallel"] is True + + +def test_ep_mismatch_raises(): + with pytest.raises(Exception) as exc: + translate_strategy_stack([_tp(2), _ep(4)]) + assert "must equal" in str(exc.value) + + +def test_duplicate_kind_rejected(): + with pytest.raises(AxisTranslationError): + translate_strategy_stack([_tp(2), _tp(2)]) + + +def test_unsupported_kind_rejected(): + spec = StrategySpec("sp", MeshAxisSpec("sp_ulysses", 2), Broadcast(), TakeRank()) + with pytest.raises(AxisTranslationError): + translate_strategy_stack([spec]) + + +def test_dp_hash_routing_rejected(): + spec = StrategySpec("dp", MeshAxisSpec("dp", 2), PartitionByHash(), Union()) + with pytest.raises(NotImplementedError): + translate_strategy_stack([spec]) + + +def test_dp_invalid_routing_policy_rejected(): + spec = StrategySpec("dp", MeshAxisSpec("dp", 2), RouteByStage("bogus"), Union()) + with pytest.raises(AxisTranslationError): + translate_strategy_stack([spec]) + + +def test_lb_policy_never_in_engine_kwargs_even_when_replicated(): + # omni_lb_policy is pipeline-wide (applied at engine construction), not a + # per-stage engine arg, so it must never leak into as_engine_kwargs(). + cfg = translate_strategy_stack([_tp(2), _stage_replica(2, "least_queue")]) + assert cfg.omni_lb_policy == "least-queue-length" + assert "omni_lb_policy" not in cfg.as_engine_kwargs() + + +def test_stage_replica_hash_routing_rejected(): + spec = StrategySpec("sr", MeshAxisSpec("stage_replica", 2), RouteByStage("hash"), FanInByStage()) + with pytest.raises(NotImplementedError): + translate_strategy_stack([spec]) + + +def test_dp_wrong_owner_rejected(): + spec = StrategySpec( + "dp", + MeshAxisSpec("dp", 2), + RouteByStage("round_robin"), + Union(), + shard_extension={"l1_owner": "delegated"}, + ) + with pytest.raises(AxisTranslationError): + translate_strategy_stack([spec]) + + +def test_stage_replica_wrong_owner_rejected(): + spec = StrategySpec( + "sr", + MeshAxisSpec("stage_replica", 2), + RouteByStage("round_robin"), + FanInByStage(), + shard_extension={"l1_owner": "engine"}, + ) + with pytest.raises(AxisTranslationError): + translate_strategy_stack([spec]) + + +@pytest.mark.parametrize( + "specs", + [ + [_tp(2)], + [_dp(2)], + [_tp(2), _dp(2)], + [_tp(2), _ep(2)], + [_tp(2), _stage_replica(2)], + [_pp(2)], + ], +) +def test_as_engine_kwargs_are_valid_omni_engine_args(specs): + # Guard against desync: every key emitted by as_engine_kwargs() must be a + # real OmniEngineArgs field, otherwise constructing the per-stage engine args + # would explode at runtime. Keep this CPU-only by checking field names rather + # than instantiating the engine. + from vllm_omni.engine.arg_utils import OmniEngineArgs + + valid_fields = {f.name for f in dataclasses.fields(OmniEngineArgs)} + cfg = translate_strategy_stack(specs) + kwargs = cfg.as_engine_kwargs() + unknown = set(kwargs) - valid_fields + assert not unknown, f"as_engine_kwargs emitted non-OmniEngineArgs fields: {unknown}" diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index 31850401b..ff467dc65 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -739,7 +739,7 @@ def test_resolve_stage_configs_injects_global_diffusion_attention_when_missing(m monkeypatch.setattr( engine_mod, "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg]), + lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), ) _config_path, stage_configs = engine._resolve_stage_configs( @@ -772,7 +772,7 @@ def test_resolve_stage_configs_preserves_stage_diffusion_attention(monkeypatch): monkeypatch.setattr( engine_mod, "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg]), + lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), ) _config_path, stage_configs = engine._resolve_stage_configs( @@ -798,7 +798,7 @@ def test_resolve_stage_configs_does_not_inject_diffusion_attention_into_llm_stag monkeypatch.setattr( engine_mod, "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg]), + lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), ) _config_path, stage_configs = engine._resolve_stage_configs( diff --git a/tests/engine/test_strategy_lb_policy.py b/tests/engine/test_strategy_lb_policy.py new file mode 100644 index 000000000..8531c2a99 --- /dev/null +++ b/tests/engine/test_strategy_lb_policy.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Tests for strategy-derived omni_lb_policy threading into AsyncOmniEngine. + +``AsyncOmniEngine._apply_strategy_lb_policy`` only touches ``self._omni_lb_policy`` +and module-level logging, so we exercise it with a lightweight stub ``self`` to +avoid standing up a full engine (which needs GPUs/model weights). +""" + +from __future__ import annotations + +import pytest + +from vllm_omni.engine.async_omni_engine import AsyncOmniEngine + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Stub: + def __init__(self, current: str = "random") -> None: + self._omni_lb_policy = current + + +_apply = AsyncOmniEngine._apply_strategy_lb_policy + + +def test_no_derived_policy_is_noop(): + stub = _Stub("random") + _apply(stub, None, {}) + assert stub._omni_lb_policy == "random" + + +def test_derived_overrides_default_random(): + # "random" is the engine default == "unset", so the strategy value wins. + stub = _Stub("random") + _apply(stub, "round-robin", {}) + assert stub._omni_lb_policy == "round-robin" + + +def test_derived_overrides_when_flag_absent(): + stub = _Stub("random") + _apply(stub, "least-queue-length", {"omni_lb_policy": None}) + assert stub._omni_lb_policy == "least-queue-length" + + +def test_explicit_matching_flag_is_fine(): + stub = _Stub("round-robin") + _apply(stub, "round-robin", {"omni_lb_policy": "round-robin"}) + assert stub._omni_lb_policy == "round-robin" + + +def test_explicit_conflicting_flag_raises(): + stub = _Stub("round-robin") + with pytest.raises(ValueError, match="Conflicting load-balancer policy"): + _apply(stub, "least-queue-length", {"omni_lb_policy": "round-robin"}) + + +def test_explicit_random_is_treated_as_unset(): + # An explicit --omni-lb-policy=random is indistinguishable from the default, + # so a strategy value still overrides it (documented Phase-1 semantics). + stub = _Stub("random") + _apply(stub, "round-robin", {"omni_lb_policy": "random"}) + assert stub._omni_lb_policy == "round-robin" diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index 61a420a4c..1f18d975b 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -343,7 +343,7 @@ def test_resolve_stage_configs_injects_additional_config_into_diffusion_stage(mo ) mocker.patch( "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - return_value=("dummy.yaml", [fake_llm_stage, fake_diffusion_stage]), + return_value=("dummy.yaml", [fake_llm_stage, fake_diffusion_stage], None), ) engine = AsyncOmniEngine.__new__(AsyncOmniEngine) diff --git a/tests/entrypoints/test_resolve_dreamzero_config.py b/tests/entrypoints/test_resolve_dreamzero_config.py index e699edde4..4dcaddd63 100644 --- a/tests/entrypoints/test_resolve_dreamzero_config.py +++ b/tests/entrypoints/test_resolve_dreamzero_config.py @@ -32,7 +32,7 @@ def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch): lambda _model: True, ) - stage_configs = load_stage_configs_from_model("GEAR-Dreams/DreamZero-DROID") + stage_configs, _ = load_stage_configs_from_model("GEAR-Dreams/DreamZero-DROID") engine_args = stage_configs[0].engine_args assert engine_args.model_class_name == "DreamZeroPipeline" diff --git a/tests/entrypoints/test_serve.py b/tests/entrypoints/test_serve.py index 6c4445306..61ffed3d5 100644 --- a/tests/entrypoints/test_serve.py +++ b/tests/entrypoints/test_serve.py @@ -9,6 +9,7 @@ import pytest from pytest_mock import MockerFixture from vllm_omni.entrypoints.cli.serve import OmniServeCommand, run_headless +from vllm_omni.entrypoints.utils import parse_stage_overrides from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -31,6 +32,20 @@ def test_serve_parser_accepts_no_async_chunk_and_marks_it_explicit() -> None: assert not explicit["async_chunk"] +def test_serve_parser_accepts_strategy_config() -> None: + """``--strategy-config`` must parse onto the ``strategy_config`` dest and be + forwarded as an explicit kwarg so the engine can overlay the strategy.""" + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + cmd = OmniServeCommand() + cmd.subparser_init(subparsers) + + argv = ["serve", "fake-model", "--omni", "--strategy-config", "/tmp/strategy.yaml"] + args = parser.parse_args(argv) + assert args.strategy_config == "/tmp/strategy.yaml" + assert args.get_explicit_kwargs_dict()["strategy_config"] == "/tmp/strategy.yaml" + + def _make_headless_args(**kwargs) -> TrackingNamespace: defaults = { "model": "fake-model", @@ -80,13 +95,97 @@ def test_run_headless_rejects_non_multiprocess_worker_backend() -> None: run_headless(args) +# --------------------------------------------------------------------------- +# --stage-overrides parsing parity (headless vs standard path) +# --------------------------------------------------------------------------- + + +def test_parse_stage_overrides_valid_json() -> None: + """A valid JSON string is parsed into the nested per-stage dict.""" + parsed = parse_stage_overrides('{"0": {"devices": "0,1"}, "1": {"devices": "2"}}') + assert parsed == {"0": {"devices": "0,1"}, "1": {"devices": "2"}} + + +def test_parse_stage_overrides_none_and_empty_return_none() -> None: + """No overrides (None / empty string) resolve to ``None``.""" + assert parse_stage_overrides(None) is None + assert parse_stage_overrides("") is None + + +def test_parse_stage_overrides_empty_dict_returns_none() -> None: + """An empty dict is falsy and must resolve to ``None``, locking in parity + with the original standard-path ``if stage_overrides_json:`` falsy check.""" + assert parse_stage_overrides({}) is None + + +def test_parse_stage_overrides_passes_through_non_str() -> None: + """An already-parsed mapping is returned unchanged (identity).""" + overrides = {"0": {"devices": "0"}} + assert parse_stage_overrides(overrides) is overrides + + +def test_parse_stage_overrides_invalid_json_raises() -> None: + """Invalid JSON raises ValueError whose message matches the standard path + verbatim: the ``--stage-overrides is not valid JSON:`` prefix AND the + ``Got: `` suffix echoing the raw input.""" + bad = "{not valid json}" + with pytest.raises(ValueError) as excinfo: + parse_stage_overrides(bad) + message = str(excinfo.value) + assert message.startswith("--stage-overrides is not valid JSON:") + assert f"Got: {bad!r}" in message + + +def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) -> None: + """Regression: the headless path must parse ``--stage-overrides`` (a JSON + string) and forward the parsed dict to ``load_and_resolve_stage_configs``, + mirroring the standard engine path. Previously it was dropped entirely, + silently producing a different per-stage device layout.""" + captured: dict = {} + + def _fake_resolve(*args, **kwargs): + captured.update(kwargs) + # Return a stage that does NOT match stage_id=0 so run_headless stops + # right after the resolver call (we only care about how it was called). + return ("/fake/stages.yaml", [SimpleNamespace(stage_id=99)], None) + + mocker.patch( + "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", + side_effect=_fake_resolve, + ) + + args = _make_headless_args( + stage_id=0, + strategy_config="/tmp/strategy.yaml", + stage_overrides='{"0": {"devices": "0,1"}, "1": {"devices": "2"}}', + ) + with pytest.raises(ValueError, match="No stage config found for stage_id=0"): + run_headless(args) + + assert captured["stage_overrides"] == {"0": {"devices": "0,1"}, "1": {"devices": "2"}} + assert captured["strategy_config_path"] == "/tmp/strategy.yaml" + + +def test_run_headless_invalid_stage_overrides_raises(mocker: MockerFixture) -> None: + """Invalid ``--stage-overrides`` JSON in headless mode fails fast with the + shared ValueError instead of being silently ignored.""" + mocker.patch( + "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", + return_value=("/fake/stages.yaml", [SimpleNamespace(stage_id=0)], None), + ) + + args = _make_headless_args(stage_id=0, stage_overrides="{not valid json}") + with pytest.raises(ValueError, match="--stage-overrides is not valid JSON"): + run_headless(args) + + def test_run_headless_raises_when_stage_id_not_in_configs(mocker: MockerFixture) -> None: """Headless looks up its assigned stage_id in the loaded deploy YAML and fails fast when the launcher's --stage-id doesn't match any entry.""" other_stage = SimpleNamespace(stage_id=99) mocker.patch( "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [other_stage]), + return_value=("/fake/stages.yaml", [other_stage], None), ) args = _make_headless_args(stage_id=0) @@ -134,7 +233,7 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker mocker.patch( "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg]), + return_value=("/fake/stages.yaml", [stage_cfg], None), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -210,7 +309,7 @@ def test_run_headless_llm_launches_one_manager_per_omni_dp_size_local(mocker: Mo mocker.patch( "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg]), + return_value=("/fake/stages.yaml", [stage_cfg], None), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -269,7 +368,7 @@ def test_run_headless_diffusion_registers_and_spawns_proc(mocker: MockerFixture) mocker.patch( "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg]), + return_value=("/fake/stages.yaml", [stage_cfg], None), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -349,7 +448,7 @@ def test_run_headless_diffusion_raises_on_nonzero_proc_exit(mocker: MockerFixtur mocker.patch( "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg]), + return_value=("/fake/stages.yaml", [stage_cfg], None), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/test_utils.py index 479ada963..b48ed2d91 100644 --- a/tests/entrypoints/test_utils.py +++ b/tests/entrypoints/test_utils.py @@ -318,7 +318,7 @@ class TestLoadAndResolveStageConfigs: def test_load_and_resolve_with_kwargs(self): """Ensure that dtype survives default stage creation.""" kwargs = {"dtype": torch.float32} - config_path, stage_configs = load_and_resolve_stage_configs( + config_path, stage_configs, _ = load_and_resolve_stage_configs( model="black-forest-labs/FLUX.2-klein-4B", stage_configs_path=None, kwargs=kwargs, @@ -349,10 +349,10 @@ class TestLoadAndResolveStageConfigs: ] load_stage_configs = mocker.patch( "vllm_omni.entrypoints.utils.load_stage_configs_from_model", - return_value=returned_stage_configs, + return_value=(returned_stage_configs, None), ) - config_path, stage_configs = load_and_resolve_stage_configs( + config_path, stage_configs, _ = load_and_resolve_stage_configs( model="dummy-model", stage_configs_path=str(deploy_path), kwargs={}, @@ -363,6 +363,7 @@ class TestLoadAndResolveStageConfigs: base_engine_args={}, deploy_config_path=str(deploy_path), stage_overrides=None, + strategy_config_path=None, ) assert config_path == str(deploy_path) assert len(stage_configs) == 2 diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py index c1c76180a..54683e67c 100644 --- a/tests/test_config_factory.py +++ b/tests/test_config_factory.py @@ -1721,7 +1721,7 @@ class TestAuraOmniDeploy: def test_aura_omni_deploy_resolves_four_native_stages(self): pipeline_cfg = StageConfigFactory.resolve_pipeline_config("aura_omni") - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( "qwen3_tts", pipeline_cfg, cli_overrides={}, @@ -1943,11 +1943,12 @@ class TestSentinelDefaultPrecedence: model_type, Q3_OMNI_ALL_STAGES_HF_CONFIG, ) - return StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( "qwen3_omni_moe", pipeline_cfg, cli_overrides=cli_overrides, ) + return stages def test_typed_kwarg_overrides_yaml(self): stages = self._stages({"max_num_seqs": 999}) diff --git a/tests/utils/test_tracking_parser.py b/tests/utils/test_tracking_parser.py index d2e7e4034..5bee3e3b0 100644 --- a/tests/utils/test_tracking_parser.py +++ b/tests/utils/test_tracking_parser.py @@ -591,7 +591,7 @@ def test_explicit_cli_arg_reaches_runtime_overrides(mock_stages): ns = p.parse_args(["--max-num-seqs", "999"]) explicit_kwargs = ns.get_explicit_kwargs_dict() - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( _TEST_MODEL, _TEST_PIPELINE, explicit_kwargs, @@ -608,7 +608,7 @@ def test_omitted_default_not_in_runtime_overrides(mock_stages): ns = p.parse_args([]) explicit_kwargs = ns.get_explicit_kwargs_dict() - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( _TEST_MODEL, _TEST_PIPELINE, explicit_kwargs, @@ -630,7 +630,7 @@ def test_config_file_args_reach_runtime_overrides(mock_stages): ns = p.parse_args(["--config", "fake.yaml"]) explicit_kwargs = ns.get_explicit_kwargs_dict() - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( _TEST_MODEL, _TEST_PIPELINE, explicit_kwargs, @@ -648,7 +648,7 @@ def test_per_stage_override_routes_correctly(mock_stages): ns = p.parse_args(["--stage-0-gpu-memory-utilization", "0.42"]) explicit_kwargs = ns.get_explicit_kwargs_dict() - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( _TEST_MODEL, _TEST_PIPELINE, explicit_kwargs, @@ -670,7 +670,7 @@ def test_explicit_args_omitted_from_yaml(mock_stages): ns = p.parse_args(["--enforce-eager"]) explicit_kwargs = ns.get_explicit_kwargs_dict() - stages = StageConfigFactory._create_from_registry( + stages, _ = StageConfigFactory._create_from_registry( _TEST_MODEL, _TEST_PIPELINE, explicit_kwargs, diff --git a/vllm_omni/config/composable_parallel/__init__.py b/vllm_omni/config/composable_parallel/__init__.py new file mode 100644 index 000000000..499c14548 --- /dev/null +++ b/vllm_omni/config/composable_parallel/__init__.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Composable parallel strategies for vLLM-Omni. + +A small, runtime-agnostic vocabulary for declaring a stage's parallel layout +(:class:`StrategySpec` + mesh/routing/aggregation patterns), plus the logic to +translate a per-role stack of specs into concrete engine sizing and overlay it +onto merged stage configs. + +The spec/pattern *types* are deliberately pure data with no engine imports; the +translation and apply logic lives alongside them and consumes vLLM-Omni's own +config objects. +""" + +from vllm_omni.config.composable_parallel.aggregation import ( + AGGREGATION_PATTERN_VARIANTS, + AggregationConflictError, + AggregationPattern, + AllGather, + Combine, + FanInByStage, + GatherDim, + StitchPipeline, + StitchSpatial, + TakeRank, + Union, +) +from vllm_omni.config.composable_parallel.apply import ( + StrategyApplyError, + StrategyApplyResult, + apply_strategy_specs, + check_device_layout, +) +from vllm_omni.config.composable_parallel.routing import ( + ROUTING_PATTERN_VARIANTS, + Broadcast, + DuplicateWithCondTag, + PartitionByHash, + PipelineMicrobatch, + RouteByExpert, + RouteByStage, + RoutingKeyError, + RoutingPattern, + ShardSequence, + ShardSpatial, +) +from vllm_omni.config.composable_parallel.spec import ( + MESH_AXIS_KINDS, + KernelSpec, + LayerHookSpec, + MeshAxisKind, + MeshAxisSpec, + SpecMergeConflictError, + StrategySpec, +) +from vllm_omni.config.composable_parallel.translator import ( + AxisTranslationError, + OmniParallelConfig, + translate_strategy_stack, +) + +__all__ = [ + # spec types + "MeshAxisKind", + "MESH_AXIS_KINDS", + "MeshAxisSpec", + "LayerHookSpec", + "KernelSpec", + "StrategySpec", + "SpecMergeConflictError", + # routing patterns + "RoutingPattern", + "RoutingKeyError", + "Broadcast", + "PartitionByHash", + "ShardSequence", + "ShardSpatial", + "RouteByExpert", + "PipelineMicrobatch", + "DuplicateWithCondTag", + "RouteByStage", + "ROUTING_PATTERN_VARIANTS", + # aggregation patterns + "AggregationPattern", + "AggregationConflictError", + "TakeRank", + "Union", + "GatherDim", + "AllGather", + "StitchSpatial", + "StitchPipeline", + "Combine", + "FanInByStage", + "AGGREGATION_PATTERN_VARIANTS", + # translation + "OmniParallelConfig", + "translate_strategy_stack", + "AxisTranslationError", + # apply + "apply_strategy_specs", + "StrategyApplyResult", + "check_device_layout", + "StrategyApplyError", +] diff --git a/vllm_omni/config/composable_parallel/aggregation.py b/vllm_omni/config/composable_parallel/aggregation.py new file mode 100644 index 000000000..75ed8fadd --- /dev/null +++ b/vllm_omni/config/composable_parallel/aggregation.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Closed aggregation-pattern hierarchy for declarative parallel strategies. + +An aggregation pattern describes how the per-worker results of one mesh axis +are recombined into a single logical result (e.g. take one tensor-parallel +rank's output, union disjoint data-parallel shards). The hierarchy is *closed*: +subclasses may only be defined in this module. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AggregationPattern: + """Base class for closed aggregation patterns. Subclass only within this module.""" + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if cls.__module__ != __name__: + raise TypeError("AggregationPattern is closed to external subclassing") + + def pattern_kind(self) -> str: + raise NotImplementedError + + +class AggregationConflictError(ValueError): + """Raised when two aggregation inputs conflict for the same request id.""" + + +@dataclass(frozen=True) +class TakeRank(AggregationPattern): + """Select one rank's result (TP — all ranks agree).""" + + rank: int = 0 + + def __post_init__(self) -> None: + if not isinstance(self.rank, int) or isinstance(self.rank, bool): + raise TypeError(f"TakeRank.rank must be int, got {type(self.rank).__name__}") + + def pattern_kind(self) -> str: + return "take_rank" + + +@dataclass(frozen=True) +class Union(AggregationPattern): + """Union of disjoint replica results (DP, HSDP).""" + + def pattern_kind(self) -> str: + return "union" + + +@dataclass(frozen=True) +class GatherDim(AggregationPattern): + """Gather along a dimension and materialize on one designated rank. + + Default designated rank is axis-rank 0 unless adapter overrides. + """ + + dim: int = 1 + + def pattern_kind(self) -> str: + return "gather_dim" + + +@dataclass(frozen=True) +class AllGather(AggregationPattern): + """All ranks materialize the same aggregate result across the axis.""" + + dim: int | None = None + + def pattern_kind(self) -> str: + return "all_gather" + + +@dataclass(frozen=True) +class StitchSpatial(AggregationPattern): + """Stitch spatial patches (VAE-PP).""" + + grid: tuple[int, int] = (1, 1) + + def pattern_kind(self) -> str: + return "stitch_spatial" + + +@dataclass(frozen=True) +class StitchPipeline(AggregationPattern): + """Stitch pipeline stage outputs (PP).""" + + def pattern_kind(self) -> str: + return "stitch_pipeline" + + +@dataclass(frozen=True) +class Combine(AggregationPattern): + """Custom reducer combination (CFG). Adapter resolves reducer_id.""" + + reducer_id: str = "default" + + def pattern_kind(self) -> str: + return "combine" + + +@dataclass(frozen=True) +class FanInByStage(AggregationPattern): + """Fan-in results routed by stage (stage replicas).""" + + def pattern_kind(self) -> str: + return "fan_in_by_stage" + + +AGGREGATION_PATTERN_VARIANTS: tuple[type[AggregationPattern], ...] = ( + TakeRank, + Union, + GatherDim, + AllGather, + StitchSpatial, + StitchPipeline, + Combine, + FanInByStage, +) + +ReducerFn = Callable[..., object] diff --git a/vllm_omni/config/composable_parallel/apply.py b/vllm_omni/config/composable_parallel/apply.py new file mode 100644 index 000000000..879c7aec3 --- /dev/null +++ b/vllm_omni/config/composable_parallel/apply.py @@ -0,0 +1,264 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Apply declarative parallel strategies onto merged stage configs. + +This is the *override-after-merge* seam. ``merge_pipeline_deploy`` first fuses +the pipeline topology + deploy YAML into a list of ``StageConfig`` objects; this +module then overlays the engine sizing derived from a per-role +``StrategySpec`` stack onto those stages. + +Design rules (one writer per axis): + +* A strategy only writes the axes it actually declares. If a stack has no ``tp`` + axis, this module never touches ``tensor_parallel_size`` — it does not force a + default of 1. +* **Conflict-on-explicit.** If a knob was already set explicitly by the deploy + YAML and the strategy derives a different value, we raise rather than silently + override. Equal values are a no-op; unset values are filled. +* **Pre-spawn device check.** When a stage declares ``devices``, the device + count must be consistent with the engine world size (``tp * dp * pp``) and the + replica count, so misconfigurations fail at config time instead of at spawn. + +``omni_lb_policy`` is *not* a per-stage config knob: omni reads it once at +orchestrator construction (``AsyncOmniEngine``), not from stage configs. So a +stage_replica axis's derived policy is surfaced on :class:`StrategyApplyResult` +for the caller to pass to the orchestrator; it is never silently written into a +stage where it would be ignored. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, NoReturn + +from vllm.logger import init_logger + +from vllm_omni.config.composable_parallel.spec import StrategySpec +from vllm_omni.config.composable_parallel.translator import ( + OmniParallelConfig, + translate_strategy_stack, +) + +logger = init_logger(__name__) + +# Role key -> the StageConfig fields each declared axis writes. +_ENGINE_FIELD_BY_KIND: dict[str, str] = { + "tp": "tensor_parallel_size", + "dp": "data_parallel_size", + "pp": "pipeline_parallel_size", +} + +RoleKey = str + + +class StrategyApplyError(ValueError): + """Error for applying a strategy onto stage configs. + + A single error type (rather than per-cause subclasses) keeps the public + surface small and consistent with the rest of the codebase; the specific + cause — an unmatched role, a conflict with an explicit deploy value, or a + device-count mismatch — is in the message and is logged before the raise so + it stays visible even when the type is unavailable to a caller (e.g. across a + server boundary). + """ + + +def _fail(msg: str) -> NoReturn: + """Log and raise :class:`StrategyApplyError`.""" + logger.error("[composable_parallel] %s", msg) + raise StrategyApplyError(msg) + + +@dataclass +class StrategyApplyResult: + """Outcome of applying strategies to a stage list. + + ``stages`` is the same list passed in (mutated in place and returned for + convenience). ``omni_lb_policy`` is the pipeline-wide load-balancer policy + derived from any stage_replica axes (``None`` if none declared one); the + caller wires it into the orchestrator, since omni does not read it from + stage configs. + + ``per_role_config`` is keyed by the role key the caller supplied — a + ``model_stage`` name (str). ``per_stage_config`` is the same information + keyed by the resolved integer ``stage_id`` — handy for re-validating a + stage after later layers (e.g. CLI overrides) have run. + """ + + stages: list[Any] + omni_lb_policy: str | None = None + per_role_config: dict[RoleKey, OmniParallelConfig] = field(default_factory=dict) + per_stage_config: dict[int, OmniParallelConfig] = field(default_factory=dict) + + +def _resolve_stage(stages: Sequence[Any], key: RoleKey) -> Any: + """Find the single stage matching ``key`` by ``model_stage`` name. + + Role keys are ``model_stage`` names (str) — the same human-readable names + that strategy.yaml authors use to refer to a stage (e.g. ``"thinker"``). + """ + matches = [s for s in stages if getattr(s, "model_stage", None) == key] + descriptor = f"model_stage={key!r}" + + if not matches: + available = ", ".join(f"{getattr(s, 'model_stage', '?')!r}(id={getattr(s, 'stage_id', '?')})" for s in stages) + _fail(f"strategy role {descriptor} did not match any stage; available stages: {available}") + if len(matches) > 1: + _fail(f"strategy role {descriptor} is ambiguous; it matched {len(matches)} stages") + return matches[0] + + +def _set_explicit(engine_args: dict[str, Any], field_name: str, value: Any, *, role: RoleKey) -> None: + """Write ``field_name`` with conflict-on-explicit semantics. + + ``engine_args`` is the raw mapping loaded straight from the deploy YAML + (before the engine-args dataclass is constructed), so a key is *present* only + when the YAML set it explicitly. We therefore key off membership rather than + ``.get``: an explicit ``None`` in the YAML is a real, present value and must + not be conflated with a key miss (which ``.get`` would report as ``None``). + """ + if field_name in engine_args and engine_args[field_name] != value: + existing = engine_args[field_name] + _fail( + f"role {role!r}: strategy derives {field_name}={value} but the deploy config already " + f"set {field_name}={existing}. Remove one of them (the strategy is the single writer " + "for the axes it declares)." + ) + engine_args[field_name] = value + + +def _set_num_replicas(runtime: dict[str, Any], value: int, *, role: RoleKey) -> None: + """Write ``num_replicas`` with conflict-on-explicit (treating 1 as unset).""" + existing = runtime.get("num_replicas", 1) + if existing not in (1, value): + _fail( + f"role {role!r}: strategy derives num_replicas={value} but the deploy config already " + f"set num_replicas={existing}. Remove one of them." + ) + runtime["num_replicas"] = value + + +def _parse_device_count(devices: Any) -> int | None: + """Return the number of device ids in a ``devices`` value, or None.""" + if devices is None: + return None + if isinstance(devices, (list, tuple)): + return len([d for d in devices]) + text = str(devices).strip() + if not text: + return None + return len([d for d in text.split(",") if d.strip()]) + + +def check_device_layout( + devices: Any, + *, + tensor_parallel_size: int, + data_parallel_size: int, + pipeline_parallel_size: int, + num_replicas: int, + role: RoleKey, +) -> None: + """Validate a stage's device count against its world size (× replicas in pool mode). + + Raises :class:`StrategyApplyError` when the declared ``devices`` count is + neither the per-replica world size nor the full ``num_replicas`` pool. + Exposed publicly so the resolved (post-CLI) layout can be re-checked after + later override layers, not just the strategy-derived snapshot. + """ + count = _parse_device_count(devices) + if count is None: + return + world = int(tensor_parallel_size) * int(data_parallel_size) * int(pipeline_parallel_size) + replicas = int(num_replicas or 1) + # Two valid shapes: a single per-replica template (== world) or the full + # pool across replicas (== replicas * world). This mirrors omni's own + # per-replica device splitter. + valid = {world, replicas * world} + if count not in valid: + _fail( + f"role {role!r}: declared {count} device id(s) but the strategy world size is {world} " + f"(tp={tensor_parallel_size} * dp={data_parallel_size} * pp={pipeline_parallel_size}). " + f"Provide either {world} (per-replica template) or {replicas * world} " + f"(num_replicas={replicas} pool)." + ) + + +def _check_devices(runtime: dict[str, Any], cfg: OmniParallelConfig, *, role: RoleKey) -> None: + """Pre-spawn check on the strategy-derived snapshot (see :func:`check_device_layout`).""" + check_device_layout( + runtime.get("devices"), + tensor_parallel_size=cfg.tensor_parallel_size, + data_parallel_size=cfg.data_parallel_size, + pipeline_parallel_size=cfg.pipeline_parallel_size, + num_replicas=int(runtime.get("num_replicas", 1) or 1), + role=role, + ) + + +def _apply_to_stage(stage: Any, cfg: OmniParallelConfig, *, role: RoleKey) -> None: + engine_args = stage.yaml_engine_args + runtime = stage.yaml_runtime + + declared = set(cfg.l1_owners.keys()) + for kind, field_name in _ENGINE_FIELD_BY_KIND.items(): + if kind in declared: + _set_explicit(engine_args, field_name, getattr(cfg, field_name), role=role) + if "ep" in declared and cfg.enable_expert_parallel: + _set_explicit(engine_args, "enable_expert_parallel", True, role=role) + if "stage_replica" in declared: + _set_num_replicas(runtime, cfg.stage_replica_size, role=role) + + _check_devices(runtime, cfg, role=role) + + +def apply_strategy_specs( + stages: list[Any], + strategy_specs: Mapping[RoleKey, Sequence[StrategySpec]], +) -> StrategyApplyResult: + """Overlay per-role strategy specs onto a merged stage list. + + Args: + stages: the ``list[StageConfig]`` returned by ``merge_pipeline_deploy``. + strategy_specs: maps a role (a ``model_stage`` name, e.g. ``"thinker"``) + to that stage's stack of ``StrategySpec`` (one per declared mesh + axis). Role keys are ``model_stage`` names (str). + + Returns: + A :class:`StrategyApplyResult` with the mutated stages and the derived + pipeline-wide ``omni_lb_policy`` (if any stage_replica axis set one). + + Raises: + StrategyApplyError: a role matched zero or multiple stages, a derived + value conflicts with an explicit deploy value (including two roles + deriving different ``omni_lb_policy`` values), or a stage's device + count is inconsistent with its world size. + AxisTranslationError: the spec stack is invalid/unsupported. + NotImplementedError: the spec stack requests routing not built yet. + """ + result = StrategyApplyResult(stages=stages) + lb_policy: str | None = None + lb_owner: RoleKey | None = None + + for key, specs in strategy_specs.items(): + stage = _resolve_stage(stages, key) + cfg = translate_strategy_stack(specs) + _apply_to_stage(stage, cfg, role=key) + result.per_role_config[key] = cfg + stage_id = getattr(stage, "stage_id", None) + if stage_id is not None: + result.per_stage_config[int(stage_id)] = cfg + + if cfg.stage_replica_size > 1 and cfg.omni_lb_policy is not None: + if lb_policy is not None and lb_policy != cfg.omni_lb_policy: + _fail( + f"role {key!r} derives omni_lb_policy={cfg.omni_lb_policy!r} but role " + f"{lb_owner!r} already derived {lb_policy!r}. The load-balancer policy is " + "pipeline-wide; only one value is allowed." + ) + lb_policy = cfg.omni_lb_policy + lb_owner = key + + result.omni_lb_policy = lb_policy + return result diff --git a/vllm_omni/config/composable_parallel/routing.py b/vllm_omni/config/composable_parallel/routing.py new file mode 100644 index 000000000..38e7d4a49 --- /dev/null +++ b/vllm_omni/config/composable_parallel/routing.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Closed routing-pattern hierarchy for declarative parallel strategies. + +A routing pattern describes how a request batch is distributed across the +workers of one mesh axis (e.g. broadcast to every tensor-parallel rank, hash +partition across data-parallel ranks). The hierarchy is *closed*: subclasses +may only be defined in this module, so the set of patterns is a fixed, +exhaustively matchable contract. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class RoutingPattern: + """Base class for closed routing patterns. Subclass only within this module.""" + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if cls.__module__ != __name__: + raise TypeError("RoutingPattern is closed to external subclassing") + + def pattern_kind(self) -> str: + raise NotImplementedError + + +class RoutingKeyError(KeyError): + """Raised when a routing key is missing from a request.""" + + def __init__(self, request_id: object, missing_key: str) -> None: + self.request_id = request_id + self.missing_key = missing_key + super().__init__(f"Missing routing key {missing_key!r} for request_id={request_id!r}") + + +@dataclass(frozen=True) +class Broadcast(RoutingPattern): + """Every worker in the group sees the whole batch (TP, dense EP).""" + + def pattern_kind(self) -> str: + return "broadcast" + + +@dataclass(frozen=True) +class PartitionByHash(RoutingPattern): + """Stable hash partition by request key (DP, HSDP).""" + + key: str = "request_id" + + def pattern_kind(self) -> str: + return "partition_by_hash" + + +@dataclass(frozen=True) +class ShardSequence(RoutingPattern): + """Shard along a sequence dimension (SP-Ulysses, SP-Ring, CP).""" + + dim: int = 1 + + def pattern_kind(self) -> str: + return "shard_sequence" + + +@dataclass(frozen=True) +class ShardSpatial(RoutingPattern): + """Shard along a spatial grid (VAE-PP).""" + + grid: tuple[int, int] = (1, 1) + + def pattern_kind(self) -> str: + return "shard_spatial" + + +@dataclass(frozen=True) +class RouteByExpert(RoutingPattern): + """Sparse MoE expert routing.""" + + router_id: str = "default" + + def pattern_kind(self) -> str: + return "route_by_expert" + + +@dataclass(frozen=True) +class PipelineMicrobatch(RoutingPattern): + """Pipeline parallelism microbatch routing (PP).""" + + microbatch_size: int = 1 + + def pattern_kind(self) -> str: + return "pipeline_microbatch" + + +@dataclass(frozen=True) +class DuplicateWithCondTag(RoutingPattern): + """Duplicate batch with a conditional tag (CFG).""" + + flag_name: str = "guidance_branch" + + def pattern_kind(self) -> str: + return "duplicate_with_cond_tag" + + +@dataclass(frozen=True) +class RouteByStage(RoutingPattern): + """Route requests by pipeline stage policy (stage replicas). + + The ``random``/``round_robin``/``least_queue`` policies describe stateless + load balancing that a runtime (e.g. an engine's built-in DP load balancer, + or the omni StagePool balancer) can realize. ``hash`` describes + deterministic, key-stable routing that a load balancer cannot honor on its + own (see ``PartitionByHash``). + """ + + routing_policy: Literal["random", "round_robin", "least_queue", "hash"] = "round_robin" + + def pattern_kind(self) -> str: + return "route_by_stage" + + +ROUTING_PATTERN_VARIANTS: tuple[type[RoutingPattern], ...] = ( + Broadcast, + PartitionByHash, + ShardSequence, + ShardSpatial, + RouteByExpert, + PipelineMicrobatch, + DuplicateWithCondTag, + RouteByStage, +) + +# Optional callable slot for Combine-style custom routing reducers in adapters. +RouterFn = Callable[..., object] diff --git a/vllm_omni/config/composable_parallel/spec.py b/vllm_omni/config/composable_parallel/spec.py new file mode 100644 index 000000000..299fc3ad3 --- /dev/null +++ b/vllm_omni/config/composable_parallel/spec.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Core declarative types for parallel-strategy specification. + +A :class:`StrategySpec` declares one parallelism scheme: the mesh axis it sizes +(kind + degree), how a batch is routed across that axis, and how per-worker +results are aggregated back. A *stack* of specs (one per axis) fully describes a +stage's parallel layout in a runtime-agnostic, data-only form that can be +translated into concrete engine sizing. + +The mesh-axis kinds below enumerate every parallelism dimension the contract can +describe. Only a subset is wired end-to-end today (see ``translator.py``): + +* **Wired** (translatable now): ``tp``, ``dp``, ``pp``, ``ep``, + ``stage_replica``. +* **Reserved** (declared in the type system but not yet translatable — the + translator raises ``AxisTranslationError`` for them): ``sp_ulysses``, + ``sp_ring``, ``cfg``, ``vae_pp``, ``hsdp``, ``stage_pp``, ``cp``. + +Reserved kinds fail fast at translation time rather than silently doing nothing, +so declaring one is an explicit "not yet" rather than a no-op. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Literal + +from vllm_omni.config.composable_parallel.aggregation import AggregationPattern +from vllm_omni.config.composable_parallel.routing import RoutingPattern + + +class MeshAxisKind(str, Enum): + """Enumerates every parallelism dimension the strategy contract can describe. + + Subclassing ``str`` keeps existing string comparisons and dict keys working + (``MeshAxisKind.TP == "tp"`` and both hash the same), while making the type + refactor-safe: a typo'd kind is an ``AttributeError`` on the enum rather than + a silently-wrong bare string. + """ + + TP = "tp" + DP = "dp" + PP = "pp" + EP = "ep" + SP_ULYSSES = "sp_ulysses" + SP_RING = "sp_ring" + CFG = "cfg" + VAE_PP = "vae_pp" + HSDP = "hsdp" + STAGE_PP = "stage_pp" + STAGE_REPLICA = "stage_replica" + CP = "cp" + + +# Bare string values, kept as a plain tuple so membership checks and error +# messages read as the kind names (``"tp"``) rather than enum reprs. ``str``-enum +# equality means both ``"tp"`` and ``MeshAxisKind.TP`` satisfy ``in`` checks. +MESH_AXIS_KINDS: tuple[str, ...] = tuple(k.value for k in MeshAxisKind) + +HookCategory = Literal[ + "linear", + "attention_pre", + "attention_post", + "ffn_pre", + "ffn_post", + "moe_dispatch", + "other", +] + +HOOK_CATEGORY_ORDER: dict[HookCategory, int] = { + "linear": 0, + "attention_pre": 1, + "attention_post": 2, + "ffn_pre": 3, + "ffn_post": 4, + "moe_dispatch": 5, + "other": 6, +} + + +class SpecMergeConflictError(ValueError): + """Raised when merged hook/kernel specs have conflicting declarations.""" + + +@dataclass(frozen=True) +class MeshAxisSpec: + """Declares one axis of a process mesh (kind + size).""" + + kind: MeshAxisKind + size: int + + def __post_init__(self) -> None: + if self.kind not in MESH_AXIS_KINDS: + raise ValueError(f"MeshAxisSpec.kind must be one of {MESH_AXIS_KINDS}, got {self.kind!r}") + if self.size <= 0: + raise ValueError(f"MeshAxisSpec.size must be > 0, got {self.size}") + + +@dataclass(frozen=True) +class LayerHookSpec: + """L2 hook slot referenced by StrategySpec; resolved by the model walker.""" + + hook_id: str + target: str | None = None + category: HookCategory = "other" + priority: int = 0 + axis_index: int = 0 + + +@dataclass(frozen=True) +class KernelSpec: + """L3 kernel slot referenced by StrategySpec; resolved by the kernel registry.""" + + kernel_id: str + target: str | None = None + category: HookCategory = "other" + priority: int = 0 + axis_index: int = 0 + group_axis_kind: MeshAxisKind | None = None + requires_collective: bool = False + + +@dataclass(frozen=True) +class StrategySpec: + """Declarative contract for one parallelism scheme (data + hook/kernel slots).""" + + name: str + mesh_axis: MeshAxisSpec + routing: RoutingPattern + aggregation: AggregationPattern + layer_hook_specs: tuple[LayerHookSpec, ...] = () + kernel_specs: tuple[KernelSpec, ...] = () + shard_extension: Mapping[str, Any] = field(default_factory=dict) diff --git a/vllm_omni/config/composable_parallel/strategy_loader.py b/vllm_omni/config/composable_parallel/strategy_loader.py new file mode 100644 index 000000000..665fbe9c2 --- /dev/null +++ b/vllm_omni/config/composable_parallel/strategy_loader.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Load per-role parallel strategies from a strategy file. + +The strategy file is a small, optional companion to the deploy config. It maps a +logical role (``model_stage``, e.g. ``thinker``) to a list of axis declarations, +each of which becomes one :class:`StrategySpec`. YAML is the only format wired up +today, but the module/symbol names stay format-agnostic so a different source can +be added without renaming the public surface:: + + strategies: + thinker: + - axis: tp + size: 2 + talker: + - axis: stage_replica + size: 2 + routing: round_robin # random | round_robin | least_queue + code2wav: + - axis: stage_replica + size: 2 + routing: round_robin + +Per-axis fields: + +* ``axis`` (required): mesh-axis kind (``tp``, ``dp``, ``pp``, ``ep``, + ``stage_replica`` are translatable today). +* ``size`` (required): axis degree (> 0). +* ``routing`` (optional): stateless policy for ``dp`` / ``stage_replica`` axes + (``random`` / ``round_robin`` / ``least_queue``). Ignored for axes whose + routing is fixed (``tp`` broadcast, ``pp`` microbatch). +* ``l1_owner`` (optional): ``engine`` or ``delegated``; overrides the default + owner for the axis (recorded in ``shard_extension``). +* ``name`` (optional): a label for the spec; defaults to the axis kind. + +Routing/aggregation patterns are filled in from sensible per-kind defaults so +the file stays compact; the translator validates the combination. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from vllm_omni.config.composable_parallel.aggregation import ( + AggregationPattern, + FanInByStage, + StitchPipeline, + TakeRank, + Union, +) +from vllm_omni.config.composable_parallel.routing import ( + Broadcast, + PipelineMicrobatch, + RouteByStage, + RoutingPattern, +) +from vllm_omni.config.composable_parallel.spec import MeshAxisSpec, StrategySpec +from vllm_omni.config.yaml_util import load_yaml_config, to_dict + +# Per-kind defaults for routing + aggregation, so the file only needs axis+size. +_ROUTING_POLICY_KINDS = frozenset({"dp", "stage_replica"}) + + +class StrategyLoadError(ValueError): + """Raised when a strategy file is malformed.""" + + +def _default_routing(kind: str, routing_policy: str | None) -> RoutingPattern: + if kind in _ROUTING_POLICY_KINDS: + return RouteByStage(routing_policy=routing_policy or "round_robin") # type: ignore[arg-type] + if kind == "pp": + return PipelineMicrobatch() + # tp, ep, and any other kind default to broadcast (the translator rejects + # kinds it cannot handle before it ever inspects routing). + return Broadcast() + + +def _default_aggregation(kind: str) -> AggregationPattern: + if kind == "stage_replica": + return FanInByStage() + if kind == "pp": + return StitchPipeline() + if kind in ("dp", "ep"): + return Union() + return TakeRank() + + +def _build_spec(role: str, entry: Mapping[str, Any]) -> StrategySpec: + if "axis" not in entry: + raise StrategyLoadError(f"role {role!r}: each strategy entry needs an 'axis' field") + if "size" not in entry: + raise StrategyLoadError(f"role {role!r}: strategy entry for axis {entry['axis']!r} needs a 'size'") + + kind = str(entry["axis"]) + try: + size = int(entry["size"]) + except (TypeError, ValueError) as exc: + raise StrategyLoadError(f"role {role!r}: axis {kind!r} size must be an integer, got {entry['size']!r}") from exc + + routing_policy = entry.get("routing") + if routing_policy is not None and kind not in _ROUTING_POLICY_KINDS: + raise StrategyLoadError( + f"role {role!r}: axis {kind!r} does not accept a 'routing' policy (only {sorted(_ROUTING_POLICY_KINDS)} do)" + ) + + shard_extension: dict[str, Any] = {} + l1_owner = entry.get("l1_owner") + if l1_owner is not None: + shard_extension["l1_owner"] = str(l1_owner) + + return StrategySpec( + name=str(entry.get("name", kind)), + mesh_axis=MeshAxisSpec(kind=kind, size=size), # type: ignore[arg-type] + routing=_default_routing(kind, routing_policy), + aggregation=_default_aggregation(kind), + shard_extension=shard_extension, + ) + + +def parse_strategy_specs(data: Mapping[str, Any]) -> dict[str, list[StrategySpec]]: + """Parse an already-loaded strategy mapping into per-role spec stacks.""" + strategies = data.get("strategies", data) + if not isinstance(strategies, Mapping): + raise StrategyLoadError("strategy file must contain a 'strategies' mapping of role -> list of axes") + + result: dict[str, list[StrategySpec]] = {} + for role, entries in strategies.items(): + if not isinstance(entries, (list, tuple)): + raise StrategyLoadError( + f"role {role!r}: expected a list of axis declarations, got {type(entries).__name__}" + ) + specs: list[StrategySpec] = [] + for entry in entries: + if not isinstance(entry, Mapping): + raise StrategyLoadError( + f"role {role!r}: each axis declaration must be a mapping, got {type(entry).__name__}" + ) + specs.append(_build_spec(str(role), dict(entry))) + result[str(role)] = specs + return result + + +def load_strategy_specs(path: str) -> dict[str, list[StrategySpec]]: + """Load and parse a strategy file into per-role spec stacks.""" + data = to_dict(load_yaml_config(path)) + if not isinstance(data, Mapping): + raise StrategyLoadError(f"strategy file {path!r} did not parse to a mapping") + return parse_strategy_specs(data) diff --git a/vllm_omni/config/composable_parallel/translator.py b/vllm_omni/config/composable_parallel/translator.py new file mode 100644 index 000000000..5c394dc9b --- /dev/null +++ b/vllm_omni/config/composable_parallel/translator.py @@ -0,0 +1,378 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Translate a ``StrategySpec`` stack into vLLM-Omni parallel sizing. + +Read a stack of strategy specs (one per mesh axis) and work out the +tensor/data/pipeline parallel sizes, the dense-EP flag, and the number of stage +replicas, plus who owns each axis's request routing. The result is a plain, +CPU-computable sizing struct (:class:`OmniParallelConfig`) keyed by the real +``OmniEngineArgs`` / ``EngineArgs`` field names, so the deploy layer can splat it +onto a stage's engine args. + +The distinction this module enforces — engine data parallelism (a true vLLM +intra-engine world dimension) vs. omni stage replicas (independent engines fanned +out by omni's coordinator) — is documented on the axis validators that depend on +it (see :func:`_validate_dp` and :func:`_stage_replica_lb_policy`). Routing we do +not support yet (key-stable / affinity routing) raises ``NotImplementedError``; +any other invalid spec raises :class:`AxisTranslationError`. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Literal, NoReturn, cast, get_args + +from vllm.logger import init_logger + +from vllm_omni.config.composable_parallel.routing import ( + Broadcast, + PartitionByHash, + PipelineMicrobatch, + RouteByStage, + RoutingPattern, +) +from vllm_omni.config.composable_parallel.spec import MeshAxisKind, StrategySpec + +logger = init_logger(__name__) + +# Who owns an axis's request routing. A closed string type so an unexpected raw +# value is caught statically and at runtime (``_VALID_L1_OWNERS`` is derived from +# it, keeping one source of truth) rather than silently flowing through. +L1Owner = Literal["delegated", "engine"] + +# DP, TP, PP, (dense) EP and stage_replica translate today. DP/TP/PP are true +# world dimensions vLLM realizes intra-engine; dense EP is a flag over the +# existing TP*DP ranks; stage_replica is an omni-coordinator-level fan-out of +# independent engine replicas (NOT a vLLM world dimension). The remaining kinds +# (sequence/context parallel, CFG, VAE pipelines, ...) need per-layer hooks or +# custom collectives and arrive in later stages. +_SUPPORTED_KINDS: tuple[MeshAxisKind, ...] = ("dp", "tp", "pp", "ep", "stage_replica") + +# Which EngineArgs field each axis kind *sizes*. EP and stage_replica are +# deliberately absent: EP is not an independent world dimension but an +# ``enable_expert_parallel`` flag whose degree equals tp*dp; stage_replica is a +# per-stage deploy ``num_replicas`` count, not an intra-engine world dimension. +_AXIS_TO_ENGINE_FIELD: dict[MeshAxisKind, str] = { + "tp": "tensor_parallel_size", + "dp": "data_parallel_size", + "pp": "pipeline_parallel_size", +} + +# Default L1 owner per axis kind. ``stage_replica`` is the only delegated axis: +# omni's coordinator/StagePool owns routing across replicas. ``dp`` is engine +# data parallelism, realized by vLLM's own intra-engine DP load balancer. +_DEFAULT_L1_OWNER: dict[MeshAxisKind, L1Owner] = { + "dp": "engine", + "tp": "engine", + "pp": "engine", + "ep": "engine", + "stage_replica": "delegated", +} + +# How a RouteByStage policy maps onto omni's load balancer policy string. These +# are the stateless options omni can actually do; note there's no "hash" here, +# because omni has no key-stable balancer. +_STAGE_POLICY_TO_OMNI_LB: dict[str, str] = { + "random": "random", + "round_robin": "round-robin", + "least_queue": "least-queue-length", +} + +_VALID_L1_OWNERS: frozenset[str] = frozenset(get_args(L1Owner)) + + +class AxisTranslationError(ValueError): + """Error for an invalid or unsupported strategy spec. + + A single error type (rather than a tree of subclasses) keeps the public + surface small and consistent with the rest of the codebase; the specific + cause is in the message and is logged before the raise so it is visible even + when the type is unavailable to a caller (e.g. across a server boundary). + + Strategies that are *valid but not built yet* — key-stable / affinity routing + today — raise ``NotImplementedError`` instead, to distinguish "we haven't + implemented this" from "your config is wrong". + """ + + +def _fail(msg: str) -> NoReturn: + """Log and raise :class:`AxisTranslationError` for an invalid/unsupported spec.""" + logger.error("[composable_parallel] %s", msg) + raise AxisTranslationError(msg) + + +def _not_implemented(msg: str) -> NoReturn: + """Log and raise ``NotImplementedError`` for a valid-but-unbuilt strategy.""" + logger.error("[composable_parallel] %s", msg) + raise NotImplementedError(msg) + + +@dataclass(frozen=True) +class OmniParallelConfig: + """Result of translating a spec stack into omni parallel sizing.""" + + tensor_parallel_size: int = 1 + data_parallel_size: int = 1 + pipeline_parallel_size: int = 1 + # Dense expert parallelism: a flag over the TP*DP ranks, not a world axis. + enable_expert_parallel: bool = False + # Number of independent omni stage replicas (the ``stage_replica`` axis). + # This is NOT a vLLM world dimension; it is the per-stage deploy + # ``num_replicas`` count. 1 means a single (un-replicated) engine. + stage_replica_size: int = 1 + # The omni StagePool LB policy string for the (delegated) stage_replica + # axis, if any. Only meaningful when ``stage_replica_size > 1``. + omni_lb_policy: str | None = None + # axis kind -> resolved L1 owner ("delegated" | "engine"). + l1_owners: Mapping[MeshAxisKind, L1Owner] = field(default_factory=dict) + + @property + def world_size(self) -> int: + # EP and stage_replica are intentionally excluded. EP reuses the TP*DP + # ranks rather than adding a dimension; stage_replica spins up separate + # engines (each its own world), not extra ranks in this engine's group. + # This product is what vLLM calls ``world_size_across_dp`` (tp * pp * dp). + return self.tensor_parallel_size * self.data_parallel_size * self.pipeline_parallel_size + + @property + def delegated_axes(self) -> tuple[MeshAxisKind, ...]: + return tuple(kind for kind, owner in self.l1_owners.items() if owner == "delegated") + + def as_engine_kwargs(self) -> dict[str, object]: + """Return per-stage kwargs keyed by real OmniEngineArgs/EngineArgs field names. + + Two derived values are intentionally *not* emitted here because they are + not per-stage engine args: + + * ``stage_replica_size`` — a per-stage deploy ``num_replicas`` knob + (``StageDeployConfig``); the deploy layer consumes it separately. + * ``omni_lb_policy`` — a pipeline-wide load-balancer policy the engine + reads once at construction (see ``StrategyApplyResult.omni_lb_policy``); + it is applied at the orchestrator level, not folded into per-stage args. + """ + kwargs: dict[str, object] = { + "tensor_parallel_size": self.tensor_parallel_size, + "data_parallel_size": self.data_parallel_size, + "pipeline_parallel_size": self.pipeline_parallel_size, + } + if self.enable_expert_parallel: + kwargs["enable_expert_parallel"] = True + return kwargs + + +def _is_affinity_dp_routing(routing: RoutingPattern) -> bool: + """True when DP routing demands key-stable (hash) placement.""" + if isinstance(routing, PartitionByHash): + return True + if isinstance(routing, RouteByStage) and routing.routing_policy == "hash": + return True + return False + + +def _resolve_l1_owner(spec: StrategySpec) -> L1Owner: + kind = spec.mesh_axis.kind + declared_owner = spec.shard_extension.get("l1_owner") + owner = declared_owner + if owner is None: + owner = _DEFAULT_L1_OWNER.get(kind, "engine") + if kind not in _DEFAULT_L1_OWNER: + logger.debug( + "[composable_parallel] axis %r has unknown kind %r with no default l1_owner; falling back to %r", + spec.name, + kind, + owner, + ) + else: + logger.debug( + "[composable_parallel] axis %r (kind %r) declared no l1_owner; using default %r", + spec.name, + kind, + owner, + ) + if owner not in _VALID_L1_OWNERS: + # Distinguish a bad value supplied via shard_extension from a bad default + # indexed out of _DEFAULT_L1_OWNER, so the source of the invalid owner is + # debuggable rather than swallowed. + source = "shard_extension" if declared_owner is not None else "_DEFAULT_L1_OWNER" + logger.debug( + "[composable_parallel] axis %r (kind %r) resolved invalid l1_owner %r from %s; valid owners are %s", + spec.name, + kind, + owner, + source, + sorted(_VALID_L1_OWNERS), + ) + _fail(f"axis {kind!r} has invalid l1_owner {owner!r}; expected one of {sorted(_VALID_L1_OWNERS)}") + return cast(L1Owner, owner) + + +def _validate_dp(spec: StrategySpec, owner: L1Owner) -> None: + """Validate an engine data-parallel axis. + + DP is realized intra-engine by vLLM's own DP load balancer, so it is + engine-owned and emits no ``omni_lb_policy`` (that string configures omni's + *replica* balancer, a different layer). Key-stable (hash) request affinity is + not something vLLM's DP LB guarantees, so it is rejected rather than silently + dropped. + """ + if owner != "engine": + _fail( + f"dp axis {spec.name!r} is engine data parallelism realized intra-engine by " + f"vLLM's DP load balancer; l1_owner must be 'engine', got {owner!r}. For " + "omni-coordinator-level request fan-out across independent replicas, use a " + "'stage_replica' axis." + ) + if _is_affinity_dp_routing(spec.routing): + _not_implemented( + f"dp axis {spec.name!r} requests key-stable (hash) routing, which vLLM's " + "intra-engine DP load balancer does not guarantee — not supported yet. Use " + "RouteByStage(random|round_robin|least_queue) for stateless DP balancing." + ) + if not isinstance(spec.routing, RouteByStage): + _fail( + f"dp axis {spec.name!r} expects RouteByStage(random|round_robin|least_queue) routing, " + f"got {type(spec.routing).__name__}" + ) + if spec.routing.routing_policy not in _STAGE_POLICY_TO_OMNI_LB: + # Recognized-but-unimplemented routing (key-stable/hash) is already + # handled above via _is_affinity_dp_routing -> _not_implemented. Anything + # left here is an unknown/invalid policy value (e.g. a typo from YAML), + # i.e. invalid input -> AxisTranslationError, not NotImplementedError. + _fail( + f"dp axis {spec.name!r} has invalid routing_policy " + f"{spec.routing.routing_policy!r}; expected one of " + f"{sorted(_STAGE_POLICY_TO_OMNI_LB)}." + ) + + +def _stage_replica_lb_policy(spec: StrategySpec, owner: L1Owner) -> str: + """Return the omni StagePool LB policy for a delegated stage_replica axis. + + A ``stage_replica`` axis is *not* a vLLM world dimension: it stands up N + independent engine replicas of one pipeline stage, coordinated by the omni + coordinator and balanced over a StagePool with stateless policies only + (random / round-robin / least-queue-length). It maps to the per-stage + ``num_replicas`` count plus the pipeline-level ``omni_lb_policy`` string, so + its ``l1_owner`` must be ``"delegated"`` (omni owns the routing). Key-stable + (hash) routing is rejected because omni has no key-stable balancer yet. + """ + if owner != "delegated": + _fail( + f"stage_replica axis {spec.name!r} must be 'delegated' to omni's StagePool load " + f"balancer; got owner {owner!r}. Replica routing is owned by omni's coordinator." + ) + + routing = spec.routing + if _is_affinity_dp_routing(routing): + _not_implemented( + f"stage_replica axis {spec.name!r} requests key-stable (hash) routing, which needs a " + "dedicated load balancer — not implemented yet. Use " + "RouteByStage(random|round_robin|least_queue) to delegate to omni's load balancer." + ) + if not isinstance(routing, RouteByStage): + _fail( + f"stage_replica axis {spec.name!r} expects RouteByStage(random|round_robin|least_queue) " + f"routing, got {type(routing).__name__}" + ) + policy = _STAGE_POLICY_TO_OMNI_LB.get(routing.routing_policy) + if policy is None: + _not_implemented( + f"stage_replica axis {spec.name!r} routing_policy {routing.routing_policy!r} has no omni LB policy" + ) + return policy + + +def _validate_tp(spec: StrategySpec, owner: L1Owner) -> None: + if not isinstance(spec.routing, Broadcast): + _fail(f"tp axis {spec.name!r} expects Broadcast routing, got {type(spec.routing).__name__}") + if owner != "engine": + _fail(f"tp axis {spec.name!r} is realized intra-engine; l1_owner must be 'engine', got {owner!r}") + + +def _validate_pp(spec: StrategySpec, owner: L1Owner) -> None: + if not isinstance(spec.routing, PipelineMicrobatch): + _fail(f"pp axis {spec.name!r} expects PipelineMicrobatch routing, got {type(spec.routing).__name__}") + if owner != "engine": + _fail(f"pp axis {spec.name!r} is realized intra-engine; l1_owner must be 'engine', got {owner!r}") + + +def _validate_ep(spec: StrategySpec, owner: L1Owner) -> None: + # Dense EP: every rank still sees the whole batch, experts are sharded + # across ranks inside the engine (sparse MoE all-to-all is a later stage). + if not isinstance(spec.routing, Broadcast): + _fail( + f"ep axis {spec.name!r} expects Broadcast routing (dense expert parallel), " + f"got {type(spec.routing).__name__}" + ) + if owner != "engine": + _fail(f"ep axis {spec.name!r} is realized intra-engine; l1_owner must be 'engine', got {owner!r}") + + +def translate_strategy_stack(specs: Sequence[StrategySpec]) -> OmniParallelConfig: + """Translate a spec stack into an ``OmniParallelConfig``. + + Supported kinds: dp (engine data parallel), tp, pp, (dense) ep, and + stage_replica (omni replicas). Raises ``NotImplementedError`` for deferred + (affinity / key-stable) routing, and :class:`AxisTranslationError` for any + other invalid spec (a kind not yet translatable, a repeated kind, an owner + incompatible with the axis kind, or unsupported routing). The EP degree must + equal tensor_parallel_size * data_parallel_size. + """ + sizes: dict[str, int] = {"tensor_parallel_size": 1, "data_parallel_size": 1, "pipeline_parallel_size": 1} + owners: dict[MeshAxisKind, L1Owner] = {} + omni_lb_policy: str | None = None + stage_replica_size = 1 + enable_expert_parallel = False + ep_size: int | None = None + + for spec in specs: + kind = spec.mesh_axis.kind + if kind not in _SUPPORTED_KINDS: + _fail( + f"axis kind {kind!r} is not translatable yet (supported: {list(_SUPPORTED_KINDS)}); " + "it is designed-for and lands in a later stage" + ) + if kind in owners: + _fail(f"axis kind {kind!r} appears more than once in the spec stack") + + owner = _resolve_l1_owner(spec) + if kind == "dp": + _validate_dp(spec, owner) + elif kind == "tp": + _validate_tp(spec, owner) + elif kind == "pp": + _validate_pp(spec, owner) + elif kind == "ep": + _validate_ep(spec, owner) + enable_expert_parallel = True + ep_size = spec.mesh_axis.size + elif kind == "stage_replica": + omni_lb_policy = _stage_replica_lb_policy(spec, owner) + stage_replica_size = spec.mesh_axis.size + + if kind in _AXIS_TO_ENGINE_FIELD: + sizes[_AXIS_TO_ENGINE_FIELD[kind]] = spec.mesh_axis.size + owners[kind] = owner + + if enable_expert_parallel: + # Dense EP shards experts across exactly the TP*DP ranks, so the declared + # EP degree must match that product (it is not its own world dimension, + # and PP is excluded). An EP axis of size 1 is a degenerate no-op that + # still sets the flag; it is only valid when TP*DP == 1. + ep_ranks = sizes["tensor_parallel_size"] * sizes["data_parallel_size"] + if ep_size != ep_ranks: + _fail( + f"ep axis size {ep_size} must equal tensor_parallel_size*data_parallel_size " + f"(={ep_ranks}); dense expert parallelism shards experts across exactly those ranks" + ) + + return OmniParallelConfig( + tensor_parallel_size=sizes["tensor_parallel_size"], + data_parallel_size=sizes["data_parallel_size"], + pipeline_parallel_size=sizes["pipeline_parallel_size"], + enable_expert_parallel=enable_expert_parallel, + stage_replica_size=stage_replica_size, + omni_lb_policy=omni_lb_policy, + l1_owners=owners, + ) diff --git a/vllm_omni/config/config_factory.py b/vllm_omni/config/config_factory.py index 4ab526a8b..1b38a3d34 100644 --- a/vllm_omni/config/config_factory.py +++ b/vllm_omni/config/config_factory.py @@ -6,6 +6,7 @@ from __future__ import annotations import dataclasses import functools +from collections.abc import Mapping from dataclasses import asdict from pathlib import Path from typing import Any @@ -33,6 +34,16 @@ from vllm_omni.diffusion.utils.hf_utils import _looks_like_dreamzero logger = init_logger(__name__) +# Default degree for any parallel axis / replica count that isn't set anywhere +# (CLI, deploy YAML, or pipeline default): a single, un-parallelized rank. +# TODO(composable_parallel): this "1" default and the per-axis device-layout +# fallbacks are currently re-derived in the merge, apply, and reconcile layers. +# Centralize them in one schema so the pre-spawn device guard and the engine-args +# defaults can't drift apart. This is the light slice; the full device-layout +# centralization is tracked as a follow-up. +_DEFAULT_PARALLEL_DEGREE = 1 + + class StageConfigFactory: """Factory that loads pipeline YAML and merges CLI overrides. @@ -267,14 +278,27 @@ class StageConfigFactory: trust_remote_code: bool = False, cli_overrides: dict[str, Any] | None = None, deploy_config_path: str | None = None, + strategy_specs: Mapping[Any, Any] | None = None, **deprecated_kwargs: Any, - ) -> list[StageConfig] | None: + ) -> tuple[list[StageConfig] | None, str | None]: """Load pipeline + deploy config, merge with CLI overrides. Checks OMNI_PIPELINES first, since supported models should be explicitly registered. If a model is not registered in OMNI_PIPELINES, tries to fall back to using the Transformers config & finding pipelines that have overlapping supported architectures. + + When ``strategy_specs`` is provided (a mapping of role -> list of + ``StrategySpec``), the derived parallel sizing is overlaid onto the + merged stages (see ``vllm_omni.config.composable_parallel``). This is + opt-in: omitting it leaves the existing merge path untouched. + + Returns ``(stages, omni_lb_policy)``: the merged stages (``None`` when the + model is not in the pipeline registry and the caller should fall back to + the legacy YAML path) and the strategy-derived, pipeline-wide + ``omni_lb_policy`` (``None`` when no stage_replica axis set one). The + policy is returned rather than threaded through a mutable out-param so the + engine can apply it without every intermediate call carrying the dict. """ if cli_overrides is None: cli_overrides = {} @@ -290,8 +314,9 @@ class StageConfigFactory: pipeline_cfg, cli_overrides, deploy_config_path, + strategy_specs=strategy_specs, ) - return None + return None, None @classmethod def _create_from_registry( @@ -300,12 +325,17 @@ class StageConfigFactory: pipeline_cfg: PipelineConfig, cli_overrides: dict[str, Any], deploy_config_path: str | None = None, + strategy_specs: Mapping[Any, Any] | None = None, **deprecated_kwargs: Any, - ) -> list[StageConfig]: + ) -> tuple[list[StageConfig], str | None]: """Create StageConfigs from pipeline registry + deploy YAML. Precedence: caller-typed (non-None) value > deploy YAML > StageDeployConfig dataclass default. + + Returns ``(stages, omni_lb_policy)`` — the strategy-derived pipeline-wide + load-balancer policy (``None`` when no strategy set one) travels with the + stages instead of through a mutable out-param. """ # Resolve deploy config path if deploy_config_path is None: @@ -338,12 +368,127 @@ class StageConfigFactory: stages = merge_pipeline_deploy(pipeline_cfg, deploy_cfg, cli_overrides) + # Overlay declarative parallel strategies (opt-in) before CLI overrides. + applied = cls._apply_strategy_specs(stages, strategy_specs) + explicit_overrides = {k: v for k, v in cli_overrides.items() if v is not None} for stage in stages: stage.runtime_overrides = cls._merge_cli_overrides(stage, explicit_overrides) - return stages + # Re-validate the resolved layout now that CLI overrides are on top. + cls._reconcile_strategy_with_cli(stages, applied) + + omni_lb_policy = applied.omni_lb_policy if applied is not None else None + return stages, omni_lb_policy + + @staticmethod + def _apply_strategy_specs( + stages: list[StageConfig], + strategy_specs: Mapping[Any, Any] | None, + ) -> Any: + """Overlay derived parallel sizing onto merged stages (opt-in). + + ``omni_lb_policy`` cannot be set from stage configs (omni reads it once + at orchestrator construction), so a derived non-default policy is logged + here and carried on the returned ``StrategyApplyResult`` for the caller + to hand to the orchestrator. + + Returns the ``StrategyApplyResult`` (or ``None`` when no strategy was + supplied) so the caller can re-validate the resolved layout once CLI + overrides have been merged on top, and read its ``omni_lb_policy``. + """ + if not strategy_specs: + return None + from vllm_omni.config.composable_parallel import apply_strategy_specs + + applied = apply_strategy_specs(stages, strategy_specs) + if applied.omni_lb_policy is not None: + logger.info( + "[composable_parallel] strategy derived omni_lb_policy=%r; it will be applied " + "to the orchestrator unless an explicit --omni-lb-policy was given.", + applied.omni_lb_policy, + ) + return applied + + @staticmethod + def _reconcile_strategy_with_cli( + stages: list[StageConfig], + applied: Any, + ) -> None: + """Reconcile CLI overrides applied *after* a strategy overlay. + + CLI overrides are applied last (in ``to_omegaconf``), so for any stage a + strategy touched we must (a) warn loudly when a CLI arg overrides a + strategy-declared axis — the strategy is meant to be the single writer + for the axes it declares, so a silent CLI win is surprising — and + (b) re-run the device-count check against the *effective* (post-CLI) + ``tp``/``dp``/``pp``/``num_replicas``/``devices`` so a CLI + ``--devices``/``--tensor-parallel-size``/``--num-replicas`` cannot slip + past the pre-spawn guard that exists to prevent silent OOMs. + """ + if applied is None: + return + from vllm_omni.config.composable_parallel import check_device_layout + + # Axis kind -> (engine-arg field, strategy-derived attribute on the cfg). + axis_fields = { + "tp": ("tensor_parallel_size", "tensor_parallel_size"), + "dp": ("data_parallel_size", "data_parallel_size"), + "pp": ("pipeline_parallel_size", "pipeline_parallel_size"), + } + + for stage in stages: + cfg = applied.per_stage_config.get(stage.stage_id) + if cfg is None: + continue + overrides = stage.runtime_overrides or {} + declared = set(cfg.l1_owners.keys()) + + for kind, (field_name, attr) in axis_fields.items(): + if kind in declared and overrides.get(field_name) is not None: + cli_val = overrides[field_name] + derived = getattr(cfg, attr) + if cli_val != derived: + logger.warning( + "[composable_parallel] stage %s: CLI %s=%s overrides the " + "strategy-derived %s=%s. The CLI value wins; remove one to avoid ambiguity.", + stage.stage_id, + field_name, + cli_val, + field_name, + derived, + ) + if "stage_replica" in declared and overrides.get("num_replicas") is not None: + cli_val = overrides["num_replicas"] + if cli_val != cfg.stage_replica_size: + logger.warning( + "[composable_parallel] stage %s: CLI num_replicas=%s overrides the " + "strategy-derived num_replicas=%s. The CLI value wins; remove one to avoid ambiguity.", + stage.stage_id, + cli_val, + cfg.stage_replica_size, + ) + + def _eff(field_name: str, fallback: Any) -> Any: + val = overrides.get(field_name) + return val if val is not None else fallback + + def _eff_degree(field_name: str, source: dict[str, Any]) -> int: + # Single place the per-axis "default to 1" fallback is applied, + # for both the override and the YAML-default sides (see the + # _DEFAULT_PARALLEL_DEGREE TODO). + value = _eff(field_name, source.get(field_name, _DEFAULT_PARALLEL_DEGREE)) + return int(value or _DEFAULT_PARALLEL_DEGREE) + + check_device_layout( + _eff("devices", stage.yaml_runtime.get("devices")), + tensor_parallel_size=_eff_degree("tensor_parallel_size", stage.yaml_engine_args), + data_parallel_size=_eff_degree("data_parallel_size", stage.yaml_engine_args), + pipeline_parallel_size=_eff_degree("pipeline_parallel_size", stage.yaml_engine_args), + num_replicas=_eff_degree("num_replicas", stage.yaml_runtime), + role=stage.model_stage, + ) @classmethod def create_default_diffusion(cls, kwargs: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 9097237fd..2fcfd43f7 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -434,6 +434,9 @@ class OrchestratorArgs: stage_configs_path: str | None = None deploy_config: str | None = None stage_overrides: str | None = None # raw JSON string; parsed downstream + # Optional composable-parallel strategy.yaml; orchestrator reads it, overlays + # derived sizing onto merged stages, then drops it before per-stage engine args. + strategy_config: str | None = None # === Mode Switches (orchestrator reads, DeployConfig redistributes) === async_chunk: bool | None = None diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 3c898ee75..e71af65d3 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -58,7 +58,10 @@ from vllm_omni.engine.stage_runtime import ( create_stage_runtime, ) from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin -from vllm_omni.entrypoints.utils import load_and_resolve_stage_configs +from vllm_omni.entrypoints.utils import ( + load_and_resolve_stage_configs, + parse_stage_overrides, +) from vllm_omni.inputs.data import OmniSamplingParams from vllm_omni.metrics.prometheus import OmniRequestCounter @@ -1123,11 +1126,41 @@ class AsyncOmniEngine: return result + def _apply_strategy_lb_policy(self, derived: str | None, kwargs: dict[str, Any]) -> None: + """Apply a strategy-derived ``omni_lb_policy`` to the engine. + + Precedence: an explicit ``--omni-lb-policy`` always wins. ``"random"`` is + the engine default and is treated as "unset" (indistinguishable from no + flag), so a strategy value overrides it. If the user explicitly passed a + non-default policy that conflicts with the strategy-derived one, raise so + the mismatch is not silently ignored. + """ + if not derived: + return + explicit = kwargs.get("omni_lb_policy") + user_set = explicit is not None and str(explicit) != "random" + if user_set: + if str(explicit) != str(derived): + raise ValueError( + f"Conflicting load-balancer policy: --omni-lb-policy={explicit!r} was given " + f"but the composable-parallel strategy derived omni_lb_policy={derived!r}. " + "Drop --omni-lb-policy to use the strategy value, or make them match." + ) + return + if self._omni_lb_policy != str(derived): + logger.info( + "[composable_parallel] applying strategy-derived omni_lb_policy=%r (was %r).", + derived, + self._omni_lb_policy, + ) + self._omni_lb_policy = str(derived) + def _resolve_stage_configs(self, model: str, kwargs: dict[str, Any]) -> tuple[str, list[Any]]: """Resolve stage configs and inject defaults shared by orchestrator/headless.""" stage_configs_path = kwargs.get("stage_configs_path", None) deploy_config_path = kwargs.pop("deploy_config", None) + strategy_config_path = kwargs.pop("strategy_config", None) stage_overrides_json = kwargs.pop("stage_overrides", None) explicit_stage_configs = kwargs.pop("stage_configs", None) if explicit_stage_configs is not None: @@ -1142,27 +1175,23 @@ class AsyncOmniEngine: base_kwargs = kwargs # Parse --stage-overrides JSON string if provided - stage_overrides = None - if stage_overrides_json: - if isinstance(stage_overrides_json, str): - try: - stage_overrides = json.loads(stage_overrides_json) - except json.JSONDecodeError as exc: - raise ValueError( - f"--stage-overrides is not valid JSON: {exc}. Got: {stage_overrides_json!r}" - ) from exc - else: - stage_overrides = stage_overrides_json + stage_overrides = parse_stage_overrides(stage_overrides_json) - config_path, stage_configs = load_and_resolve_stage_configs( + config_path, stage_configs, strategy_lb_policy = load_and_resolve_stage_configs( model, stage_configs_path, base_kwargs, default_stage_cfg_factory=lambda: self._create_default_diffusion_stage_cfg(kwargs), deploy_config_path=deploy_config_path, stage_overrides=stage_overrides, + strategy_config_path=strategy_config_path, ) + # A strategy.yaml may derive a pipeline-wide load-balancer policy. It is + # an orchestrator-level knob (read once at construction), so apply it here + # rather than as a per-stage config field. + self._apply_strategy_lb_policy(strategy_lb_policy, kwargs) + # Inject diffusion LoRA-related knobs from kwargs if not present in the stage config. for cfg in stage_configs: try: diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index b1ab30dee..86576ea55 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -254,6 +254,15 @@ class OmniServeCommand(CLISubcommand): help="Path to a deploy config YAML (new format with stages/engine_args). " "Mutually exclusive with --stage-configs-path.", ) + omni_config_group.add_argument( + "--strategy-config", + type=str, + default=None, + help="Path to a composable-parallel strategy.yaml. Only applies to " + "registry-based models (e.g. qwen2_5_omni): its derived parallel sizing " + "is overlaid onto the registry-merged stages before per-stage engine " + "args are built.", + ) omni_config_group.add_argument( "--stage-overrides", type=str, @@ -766,7 +775,10 @@ def run_headless(args: TrackingNamespace) -> None: load_omni_transfer_config_for_model, prepare_engine_environment, ) - from vllm_omni.entrypoints.utils import load_and_resolve_stage_configs + from vllm_omni.entrypoints.utils import ( + load_and_resolve_stage_configs, + parse_stage_overrides, + ) model = args.model stage_id: int | None = args.stage_id @@ -802,11 +814,18 @@ def run_headless(args: TrackingNamespace) -> None: args.replica_id, ) - config_path, stage_configs = load_and_resolve_stage_configs( + # Parse --stage-overrides (raw JSON string) exactly like the standard + # engine path (AsyncOmniEngine._resolve_stage_configs) so headless and + # standard launches resolve to the same per-stage device layout. + stage_overrides = parse_stage_overrides(args_dict.get("stage_overrides")) + + config_path, stage_configs, _ = load_and_resolve_stage_configs( model, stage_configs_path, args_dict, deploy_config_path=args_dict.get("deploy_config"), + stage_overrides=stage_overrides, + strategy_config_path=args_dict.get("strategy_config"), ) # Locate the stage config that matches stage_id. diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index 5ecbaa9c9..a2c598b31 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -1,3 +1,4 @@ +import json import os import types from collections import Counter @@ -316,7 +317,8 @@ def load_stage_configs_from_model( base_engine_args: dict | None = None, deploy_config_path: str | None = None, stage_overrides: dict[str, dict[str, Any]] | None = None, -) -> list: + strategy_config_path: str | None = None, +) -> tuple[list, str | None]: """Load stage configurations from model's default config file. For models registered in the pipeline registry (new path), uses @@ -330,9 +332,15 @@ def load_stage_configs_from_model( base_engine_args: Base engine args to merge as CLI overrides. deploy_config_path: Optional explicit deploy config path. stage_overrides: Per-stage overrides from --stage-overrides. + strategy_config_path: Optional path to a composable-parallel + ``strategy.yaml`` whose derived sizing is overlaid onto the + registry-merged stages (opt-in; ignored on the legacy YAML path). Returns: - List of stage configuration dictionaries + ``(stage_configs, omni_lb_policy)``: the list of stage configuration + dictionaries plus the strategy-derived pipeline-wide ``omni_lb_policy`` + (``None`` when no strategy set one). The policy is returned rather than + written into a caller-provided mutable dict. """ if base_engine_args is None: base_engine_args = {} @@ -343,26 +351,44 @@ def load_stage_configs_from_model( for key, val in overrides.items(): cli_overrides[f"stage_{stage_id_str}_{key}"] = val - stages = StageConfigFactory.create_from_model( + strategy_specs = None + if strategy_config_path is not None: + from vllm_omni.config.composable_parallel.strategy_loader import load_strategy_specs + + strategy_specs = load_strategy_specs(strategy_config_path) + + stages, omni_lb_policy = StageConfigFactory.create_from_model( model, trust_remote_code=cli_overrides.get("trust_remote_code", False), cli_overrides=cli_overrides, deploy_config_path=deploy_config_path, + strategy_specs=strategy_specs, ) if stages is not None: # Convert StageConfig objects to OmegaConf for backward compat - return [stage.to_omegaconf() for stage in stages] + return [stage.to_omegaconf() for stage in stages], omni_lb_policy - # Legacy fallback: load from YAML + # Legacy fallback: load from YAML. A composable-parallel strategy cannot be + # applied here (it overlays onto registry-merged stages), so warn rather than + # silently dropping the operator's --strategy-config. + if strategy_config_path is not None: + logger.warning( + "--strategy-config (%s) was provided but model %r resolves via the " + "legacy stage_configs YAML path, which does not support " + "composable-parallel strategies; the strategy is ignored. Use a " + "registry-based model to apply it.", + strategy_config_path, + model, + ) stage_config_path = resolve_model_config_path(model) if stage_config_path is None: - return [] + return [], None stage_configs = load_stage_configs_from_yaml( config_path=stage_config_path, base_engine_args=base_engine_args, prefer_stage_engine_args=True, ) - return stage_configs + return stage_configs, None def load_stage_configs_from_yaml( @@ -501,6 +527,25 @@ def filter_stages( return stage_configs +def parse_stage_overrides(value: Any) -> dict[str, dict[str, Any]] | None: + """Parse the ``--stage-overrides`` value into a per-stage override dict. + + ``value`` may be a raw JSON string (as supplied on the CLI) or an + already-parsed mapping. Returns ``None`` when no overrides are given. + + Raises: + ValueError: when ``value`` is a string that is not valid JSON. + """ + if not value: + return None + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError(f"--stage-overrides is not valid JSON: {exc}. Got: {value!r}") from exc + return value + + def load_and_resolve_stage_configs( model: str, stage_configs_path: str | None, @@ -508,7 +553,8 @@ def load_and_resolve_stage_configs( default_stage_cfg_factory: Any = None, deploy_config_path: str | None = None, stage_overrides: dict[str, dict[str, Any]] | None = None, -) -> tuple[str, list]: + strategy_config_path: str | None = None, +) -> tuple[str, list, str | None]: """Load stage configurations from model or YAML file with fallback to defaults. Args: @@ -521,9 +567,13 @@ def load_and_resolve_stage_configs( Mutually exclusive with ``stage_configs_path``. stage_overrides: Per-stage overrides from ``--stage-overrides`` JSON. Keys are stage_id strings, values are dicts of overrides. + strategy_config_path: Optional path to a composable-parallel + ``strategy.yaml`` overlaid onto the registry-merged stages. Returns: - Tuple of (config_path, stage_configs) + Tuple of ``(config_path, stage_configs, omni_lb_policy)`` — the last is + the strategy-derived pipeline-wide load-balancer policy (``None`` when no + strategy set one), returned for the engine to apply. """ if stage_configs_path is not None and deploy_config_path is not None: raise ValueError( @@ -551,13 +601,15 @@ def load_and_resolve_stage_configs( stage_configs_path, ) + omni_lb_policy: str | None = None if deploy_config_path is not None: config_path = deploy_config_path - stage_configs = load_stage_configs_from_model( + stage_configs, omni_lb_policy = load_stage_configs_from_model( model, base_engine_args=kwargs, deploy_config_path=deploy_config_path, stage_overrides=stage_overrides, + strategy_config_path=strategy_config_path, ) if not stage_configs: if default_stage_cfg_factory is not None: @@ -567,10 +619,11 @@ def load_and_resolve_stage_configs( stage_configs = [] elif stage_configs_path is None: config_path = resolve_model_config_path(model) - stage_configs = load_stage_configs_from_model( + stage_configs, omni_lb_policy = load_stage_configs_from_model( model, base_engine_args=kwargs, stage_overrides=stage_overrides, + strategy_config_path=strategy_config_path, ) if not stage_configs: if default_stage_cfg_factory is not None: @@ -585,7 +638,7 @@ def load_and_resolve_stage_configs( stage_configs = filter_stages(config_path, stage_configs, kwargs) logger.debug(f"stage_configs: {stage_configs}") - return config_path, stage_configs + return config_path, stage_configs, omni_lb_policy def get_final_stage_id_for_e2e( From b95905deb1c444ecce45e7f4cf6d3b15837eb07a Mon Sep 17 00:00:00 2001 From: Isotr0py Date: Sun, 12 Jul 2026 02:48:43 +0800 Subject: [PATCH 063/101] [Quantization] Migrate GGUF diffusion model support to OOT plugin (#4769) Signed-off-by: Isotr0py Signed-off-by: Isotr0py --- .buildkite/test-ready.yml | 21 ++ docs/user_guide/quantization/gguf.md | 9 + .../image_to_video/image_to_video.py | 2 +- .../text_to_image/text_to_image.py | 19 +- .../text_to_video/text_to_video.md | 2 +- .../text_to_video/text_to_video.py | 2 +- tests/config/test_omni_config.py | 11 + .../test_flux2_klein_gguf_adapter.py | 112 ------ .../test_qwen_image_gguf_adapter.py | 184 ---------- .../test_z_image_gguf_adapter.py | 83 ----- .../model_loader/test_diffusers_loader.py | 23 -- .../test_diffusers_loader_gguf.py | 259 -------------- .../diffusion/quantization/test_fp8_config.py | 17 +- .../quantization/test_gguf_config.py | 128 ------- .../test_trajectory_similarity_tool.py | 1 - .../test_async_omni_diffusion_config.py | 12 + tests/plugin/__init__.py | 1 + tests/plugin/test_gguf_diffusion.py | 154 +++++++++ tests/test_version.py | 23 ++ vllm_omni/config/omni_config.py | 2 +- .../model_loader/diffusers_loader.py | 119 +------ .../model_loader/gguf_adapters/__init__.py | 47 --- .../model_loader/gguf_adapters/base.py | 109 ------ .../model_loader/gguf_adapters/flux2_klein.py | 98 ------ .../model_loader/gguf_adapters/qwen_image.py | 27 -- .../model_loader/gguf_adapters/z_image.py | 43 --- .../models/flux2/flux2_transformer.py | 2 +- .../flux2_klein/flux2_klein_transformer.py | 2 +- vllm_omni/engine/async_omni_engine.py | 3 +- vllm_omni/entrypoints/cli/serve.py | 2 +- vllm_omni/plugins/__init__.py | 4 + vllm_omni/quantization/__init__.py | 5 +- vllm_omni/quantization/factory.py | 31 +- vllm_omni/quantization/gguf_config.py | 323 ------------------ ...compare_diffusion_trajectory_similarity.py | 6 - 35 files changed, 272 insertions(+), 1614 deletions(-) delete mode 100644 tests/diffusion/model_loader/gguf_adapters/test_flux2_klein_gguf_adapter.py delete mode 100644 tests/diffusion/model_loader/gguf_adapters/test_qwen_image_gguf_adapter.py delete mode 100644 tests/diffusion/model_loader/gguf_adapters/test_z_image_gguf_adapter.py delete mode 100644 tests/diffusion/model_loader/test_diffusers_loader_gguf.py delete mode 100644 tests/diffusion/quantization/test_gguf_config.py create mode 100644 tests/plugin/__init__.py create mode 100644 tests/plugin/test_gguf_diffusion.py delete mode 100644 vllm_omni/diffusion/model_loader/gguf_adapters/__init__.py delete mode 100644 vllm_omni/diffusion/model_loader/gguf_adapters/base.py delete mode 100644 vllm_omni/diffusion/model_loader/gguf_adapters/flux2_klein.py delete mode 100644 vllm_omni/diffusion/model_loader/gguf_adapters/qwen_image.py delete mode 100644 vllm_omni/diffusion/model_loader/gguf_adapters/z_image.py delete mode 100644 vllm_omni/quantization/gguf_config.py diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index 7e0942bf6..c9b60fe2c 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -684,3 +684,24 @@ steps: hostPath: path: /mnt/hf-cache type: DirectoryOrCreate + + - label: "Diffusion · GGUF Plugin Test" + soft_fail: true + source_file_dependencies: + - tests/plugin/test_gguf_diffusion.py + - vllm_omni/diffusion/model_loader + commands: + - pip install "vllm-gguf-plugin>=0.0.3" + - timeout 30m pytest -sv tests/plugin/test_gguf_diffusion.py + agents: + queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU + plugins: + - docker#v5.2.0: + image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + always-pull: true + propagate-environment: true + environment: + - "HF_HOME=/fsx/hf_cache" + - "HF_TOKEN" + volumes: + - "/fsx/hf_cache:/fsx/hf_cache" diff --git a/docs/user_guide/quantization/gguf.md b/docs/user_guide/quantization/gguf.md index e70d2d26e..206e25319 100644 --- a/docs/user_guide/quantization/gguf.md +++ b/docs/user_guide/quantization/gguf.md @@ -9,6 +9,15 @@ for the transformer. GGUF is static quantization: the quantized weights are produced before serving. +!!! note + GGUF support has migrated to OOT [vllm-gguf-plugin](https://github.com/vllm-project/vllm-gguf-plugin). Make sure you have GGUF plugin installed before serving a GGUF model. + +Before serving a GGUF model, make sure to install the [vllm-gguf-plugin](https://github.com/vllm-project/vllm-gguf-plugin): + +```bash +uv pip install vllm-gguf-plugin>=0.0.3 +``` + ## Hardware Support | Device | Support | diff --git a/examples/offline_inference/image_to_video/image_to_video.py b/examples/offline_inference/image_to_video/image_to_video.py index 1a0691ebb..2d1e70697 100644 --- a/examples/offline_inference/image_to_video/image_to_video.py +++ b/examples/offline_inference/image_to_video/image_to_video.py @@ -220,7 +220,7 @@ def parse_args() -> argparse.Namespace: "--quantization", type=str, default=None, - choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"], + choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8"], help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).", ) diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py index a8d980c6f..374356ef4 100644 --- a/examples/offline_inference/text_to_image/text_to_image.py +++ b/examples/offline_inference/text_to_image/text_to_image.py @@ -202,17 +202,11 @@ def parse_args() -> argparse.Namespace: "--quantization", type=str, default=None, - choices=["fp8", "int8", "gguf"], + choices=["fp8", "int8"], help="Quantization method for the transformer. " - "Options: 'fp8' (FP8 W8A8 on Ada/Hopper, weight-only on older GPUs), 'int8' (Int8 W8A8), 'gguf' (GGUF quantized weights). " + "Options: 'fp8' (FP8 W8A8 on Ada/Hopper, weight-only on older GPUs), 'int8' (Int8 W8A8). " "Default: None (no quantization, uses BF16).", ) - parser.add_argument( - "--gguf-model", - type=str, - default=None, - help=("GGUF file path or HF reference for transformer weights. Required when --quantization gguf is set."), - ) parser.add_argument( "--ignored-layers", type=str, @@ -402,14 +396,7 @@ def main(): # ignored_layers is specified so the list flows through OmniDiffusionConfig quant_kwargs: dict[str, Any] = {} ignored_layers = [s.strip() for s in args.ignored_layers.split(",") if s.strip()] if args.ignored_layers else None - if args.quantization == "gguf": - if not args.gguf_model: - raise ValueError("--gguf-model is required when --quantization gguf is set.") - quant_kwargs["quantization_config"] = { - "method": "gguf", - "gguf_model": args.gguf_model, - } - elif args.quantization and ignored_layers: + if args.quantization and ignored_layers: quant_kwargs["quantization_config"] = { "method": args.quantization, "ignored_layers": ignored_layers, diff --git a/examples/offline_inference/text_to_video/text_to_video.md b/examples/offline_inference/text_to_video/text_to_video.md index 4c2b5a83a..edc75bb26 100644 --- a/examples/offline_inference/text_to_video/text_to_video.md +++ b/examples/offline_inference/text_to_video/text_to_video.md @@ -231,7 +231,7 @@ python text_to_video.py \ - `--frame-rate`: generation FPS for pipelines that require it (e.g., LTX2). - `--audio-sample-rate`: audio sample rate for embedded audio (when the pipeline returns audio; LTX-2.3 outputs 48kHz audio). -- `--quantization`: quantization method (`fp8` for FP8, `gguf` for GGUF). +- `--quantization`: quantization method (such as `fp8` for FP8). - `--flow-shift`: scheduler flow_shift parameter. - `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3 (see above). diff --git a/examples/offline_inference/text_to_video/text_to_video.py b/examples/offline_inference/text_to_video/text_to_video.py index 200515a83..0f9bb8c8e 100644 --- a/examples/offline_inference/text_to_video/text_to_video.py +++ b/examples/offline_inference/text_to_video/text_to_video.py @@ -227,7 +227,7 @@ def parse_args() -> argparse.Namespace: "--quantization", type=str, default=None, - choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8", "gguf"], + choices=["fp8", "mxfp8", "mxfp4", "mxfp4_dualscale", "int8"], help="Quantization method for the transformer. mxfp8: W8A8 MXFP8 (NPU). mxfp4: W4A4 MXFP4 (NPU). mxfp4_dualscale: W4A4 MXFP4 dual-scale + BF16 fallback mixed (NPU). fp8: online FP8 (GPU).", ) diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index 8f15347d1..bf2dba701 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -751,3 +751,14 @@ def test_diffusion_config_field_classification_covers_current_fields(): "distributed_executor_backend", } <= omni_config_module._DIFFUSION_SHARED_CONFIG_FIELDS assert "prompt_file_path" in omni_config_module._DIFFUSION_RUNTIME_CONFIG_FIELDS + + +def test_diffusion_config_projection_keeps_mapping_quantization_config_serializable(): + quantization_config = { + "method": "example_quant", + "weights": "weights.bin", + } + + cfg = omni_config_module._DiffusionConfigProjection.from_kwargs(quantization_config=quantization_config) + + assert cfg.quantization_config == quantization_config diff --git a/tests/diffusion/model_loader/gguf_adapters/test_flux2_klein_gguf_adapter.py b/tests/diffusion/model_loader/gguf_adapters/test_flux2_klein_gguf_adapter.py deleted file mode 100644 index 0c81dc801..000000000 --- a/tests/diffusion/model_loader/gguf_adapters/test_flux2_klein_gguf_adapter.py +++ /dev/null @@ -1,112 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for the Flux2-Klein GGUF adapter.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -import torch - -from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter -from vllm_omni.diffusion.model_loader.gguf_adapters.flux2_klein import ( - Flux2KleinGGUFAdapter, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -def _make_od_config( - model_class_name: str = "Flux2Pipeline", - model_type: str = "flux", -): - return SimpleNamespace( - model_class_name=model_class_name, - tf_model_config={"model_type": model_type}, - ) - - -def _make_source(prefix: str = "", subfolder: str = "transformer"): - return SimpleNamespace(prefix=prefix, subfolder=subfolder) - - -def test_flux2_klein_adapter_selected_for_flux_family(): - adapter = get_gguf_adapter( - "dummy.gguf", - object(), - _make_source(), - _make_od_config(), - ) - - assert isinstance(adapter, Flux2KleinGGUFAdapter) - - -def test_flux2_klein_adapter_matches_flux_model_type(): - assert Flux2KleinGGUFAdapter.is_compatible( - _make_od_config(model_class_name="OtherPipeline", model_type="flux-dev"), - object(), - _make_source(), - ) - - -def test_flux2_klein_adapter_renames_core_projection_paths(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.flux2_klein as flux_module - - monkeypatch.setattr( - flux_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ("img_in.qweight", torch.ones((1, 1))), - ("time_in.in_layer.qweight_type", torch.tensor(1)), - ("double_blocks.0.img_attn.norm.query_norm.weight", torch.full((1, 1), 2.0)), - ("final_layer.linear.qweight", torch.full((1, 1), 3.0)), - ] - ), - ) - - adapter = Flux2KleinGGUFAdapter( - "dummy.gguf", - object(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - names = [name for name, _ in weights] - - assert "x_embedder.qweight" in names - assert "time_guidance_embed.timestep_embedder.linear_1.qweight_type" in names - assert "transformer_blocks.0.attn.norm_q.weight" in names - assert "proj_out.qweight" in names - - -def test_flux2_klein_adapter_swaps_final_adaln_shift_and_scale(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.flux2_klein as flux_module - - monkeypatch.setattr( - flux_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ( - "final_layer.adaLN_modulation.1.scale", - torch.tensor([1.0, 2.0, 3.0, 4.0]), - ) - ] - ), - ) - - adapter = Flux2KleinGGUFAdapter( - "dummy.gguf", - object(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - - assert len(weights) == 1 - assert weights[0][0] == "norm_out.linear.weight" - assert torch.equal(weights[0][1], torch.tensor([3.0, 4.0, 1.0, 2.0])) diff --git a/tests/diffusion/model_loader/gguf_adapters/test_qwen_image_gguf_adapter.py b/tests/diffusion/model_loader/gguf_adapters/test_qwen_image_gguf_adapter.py deleted file mode 100644 index 6b7392c80..000000000 --- a/tests/diffusion/model_loader/gguf_adapters/test_qwen_image_gguf_adapter.py +++ /dev/null @@ -1,184 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from types import SimpleNamespace - -import pytest - -from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter -from vllm_omni.diffusion.model_loader.gguf_adapters.qwen_image import ( - QwenImageGGUFAdapter, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -def _make_od_config( - model_class_name: str = "QwenImagePipeline", - model_type: str = "qwen_image", -): - return SimpleNamespace( - model_class_name=model_class_name, - tf_model_config={"model_type": model_type}, - ) - - -def _make_source(prefix: str = "", subfolder: str = "transformer"): - return SimpleNamespace(prefix=prefix, subfolder=subfolder) - - -class _FakeTransformer: - def __init__(self) -> None: - self._params = { - "img_in.qweight": 1, - "img_in.qweight_type": 1, - "txt_in.qweight": 1, - "txt_in.qweight_type": 1, - "proj_out.qweight": 1, - "proj_out.qweight_type": 1, - "time_text_embed.timestep_embedder.linear_1.qweight": 1, - "time_text_embed.timestep_embedder.linear_1.qweight_type": 1, - "time_text_embed.timestep_embedder.linear_2.qweight": 1, - "time_text_embed.timestep_embedder.linear_2.qweight_type": 1, - "transformer_blocks.0.img_mod.1.qweight": 1, - "transformer_blocks.0.img_mod.1.qweight_type": 1, - "transformer_blocks.0.txt_mod.1.qweight": 1, - "transformer_blocks.0.txt_mod.1.qweight_type": 1, - "transformer_blocks.0.attn.to_qkv.qweight": 1, - "transformer_blocks.0.attn.to_qkv.qweight_type": 1, - "transformer_blocks.0.attn.add_kv_proj.qweight": 1, - "transformer_blocks.0.attn.add_kv_proj.qweight_type": 1, - "transformer_blocks.0.attn.to_out.weight": 1, - "transformer_blocks.0.img_mlp.net.0.proj.qweight": 1, - "transformer_blocks.0.img_mlp.net.0.proj.qweight_type": 1, - } - - def named_parameters(self): - return list(self._params.items()) - - def named_buffers(self): - return [] - - def get_submodule(self, _prefix: str): - return self - - -def test_qwen_adapter_selected_for_qwen_image_family(): - adapter = get_gguf_adapter( - "dummy.gguf", - _FakeTransformer(), - _make_source(), - _make_od_config(), - ) - assert isinstance(adapter, QwenImageGGUFAdapter) - - -def test_qwen_adapter_matches_multiple_pipeline_variants(): - for model_class_name in ( - "QwenImagePipeline", - "QwenImageEditPipeline", - "QwenImageEditPlusPipeline", - "QwenImageLayeredPipeline", - ): - assert QwenImageGGUFAdapter.is_compatible( - _make_od_config(model_class_name=model_class_name), - _FakeTransformer(), - _make_source(), - ) - - -def test_qwen_adapter_preserves_split_projection_names(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.qwen_image as qwen_image_module - - monkeypatch.setattr( - qwen_image_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ("transformer_blocks.0.attn.to_q.qweight_type", 1), - ("transformer_blocks.0.attn.to_q.qweight", 2), - ("transformer_blocks.0.attn.to_k.qweight_type", 3), - ("transformer_blocks.0.attn.to_k.qweight", 4), - ("transformer_blocks.0.attn.to_out.0.qweight_type", 5), - ("transformer_blocks.0.attn.to_out.0.qweight", 6), - ] - ), - ) - - adapter = QwenImageGGUFAdapter( - "dummy.gguf", - _FakeTransformer(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - - assert ("transformer_blocks.0.attn.to_q.qweight_type", 1) in weights - assert ("transformer_blocks.0.attn.to_q.qweight", 2) in weights - assert ("transformer_blocks.0.attn.to_k.qweight_type", 3) in weights - assert ("transformer_blocks.0.attn.to_k.qweight", 4) in weights - assert ("transformer_blocks.0.attn.to_out.0.qweight_type", 5) in weights - assert ("transformer_blocks.0.attn.to_out.0.qweight", 6) in weights - - -def test_qwen_adapter_keeps_top_level_quantized_weights(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.qwen_image as qwen_image_module - - monkeypatch.setattr( - qwen_image_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ("img_in.qweight_type", 1), - ("img_in.qweight", 2), - ("time_text_embed.timestep_embedder.linear_1.qweight_type", 3), - ("time_text_embed.timestep_embedder.linear_1.qweight", 4), - ] - ), - ) - - adapter = QwenImageGGUFAdapter( - "dummy.gguf", - _FakeTransformer(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - - assert ("img_in.qweight_type", 1) in weights - assert ("img_in.qweight", 2) in weights - assert ("time_text_embed.timestep_embedder.linear_1.qweight_type", 3) in weights - assert ("time_text_embed.timestep_embedder.linear_1.qweight", 4) in weights - - -def test_qwen_adapter_keeps_modulation_quantized_weights(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.qwen_image as qwen_image_module - - monkeypatch.setattr( - qwen_image_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ("transformer_blocks.0.img_mod.1.qweight_type", 1), - ("transformer_blocks.0.img_mod.1.qweight", 2), - ("transformer_blocks.0.txt_mod.1.qweight_type", 3), - ("transformer_blocks.0.txt_mod.1.qweight", 4), - ] - ), - ) - - adapter = QwenImageGGUFAdapter( - "dummy.gguf", - _FakeTransformer(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - - assert ("transformer_blocks.0.img_mod.1.qweight_type", 1) in weights - assert ("transformer_blocks.0.img_mod.1.qweight", 2) in weights - assert ("transformer_blocks.0.txt_mod.1.qweight_type", 3) in weights - assert ("transformer_blocks.0.txt_mod.1.qweight", 4) in weights diff --git a/tests/diffusion/model_loader/gguf_adapters/test_z_image_gguf_adapter.py b/tests/diffusion/model_loader/gguf_adapters/test_z_image_gguf_adapter.py deleted file mode 100644 index cccea56fb..000000000 --- a/tests/diffusion/model_loader/gguf_adapters/test_z_image_gguf_adapter.py +++ /dev/null @@ -1,83 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for the Z-Image GGUF adapter.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -import torch - -from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter -from vllm_omni.diffusion.model_loader.gguf_adapters.z_image import ( - ZImageGGUFAdapter, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -def _make_od_config( - model_class_name: str = "ZImagePipeline", - model_type: str = "z_image", -): - return SimpleNamespace( - model_class_name=model_class_name, - tf_model_config={"model_type": model_type}, - ) - - -def _make_source(prefix: str = "", subfolder: str = "transformer"): - return SimpleNamespace(prefix=prefix, subfolder=subfolder) - - -def test_z_image_adapter_selected_for_z_image_family(): - adapter = get_gguf_adapter( - "dummy.gguf", - object(), - _make_source(), - _make_od_config(), - ) - - assert isinstance(adapter, ZImageGGUFAdapter) - - -def test_z_image_adapter_matches_model_type_variants(): - for model_type in ("z_image", "zimage", "z-image"): - assert ZImageGGUFAdapter.is_compatible( - _make_od_config(model_class_name="OtherPipeline", model_type=model_type), - object(), - _make_source(), - ) - - -def test_z_image_adapter_renames_known_gguf_tensor_paths(monkeypatch: pytest.MonkeyPatch): - import vllm_omni.diffusion.model_loader.gguf_adapters.z_image as z_image_module - - monkeypatch.setattr( - z_image_module, - "gguf_quant_weights_iterator", - lambda _path: iter( - [ - ("model.diffusion_model.final_layer.qweight", torch.ones((1, 1))), - ("model.diffusion_model.x_embedder.qweight_type", torch.tensor(1)), - ("transformer_blocks.0.attention.out.weight", torch.full((1, 1), 2.0)), - ("transformer_blocks.0.attention.qkv.qweight", torch.full((1, 1), 3.0)), - ] - ), - ) - - adapter = ZImageGGUFAdapter( - "dummy.gguf", - object(), - _make_source(), - _make_od_config(), - ) - - weights = list(adapter.weights_iterator()) - names = [name for name, _ in weights] - - assert "all_final_layer.2-1.qweight" in names - assert "all_x_embedder.2-1.qweight_type" in names - assert "transformer_blocks.0.attention.to_out.0.weight" in names - assert "transformer_blocks.0.attention.to_qkv.qweight" in names diff --git a/tests/diffusion/model_loader/test_diffusers_loader.py b/tests/diffusion/model_loader/test_diffusers_loader.py index 376dbe685..f884f3667 100644 --- a/tests/diffusion/model_loader/test_diffusers_loader.py +++ b/tests/diffusion/model_loader/test_diffusers_loader.py @@ -16,7 +16,6 @@ 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 @@ -111,27 +110,6 @@ def test_empty_source_prefix_keeps_full_model_strict_check(): loader.load_weights(model) -def test_qwen_model_class_selects_qwen_gguf_adapter(): - od_config = type( - "Config", - (), - { - "model_class_name": "QwenImagePipeline", - "tf_model_config": {"model_type": "qwen_image"}, - }, - )() - source = DiffusersPipelineLoader.ComponentSource( - model_or_path="dummy", - subfolder="transformer", - revision=None, - prefix="transformer.", - ) - - adapter = get_gguf_adapter("dummy.gguf", object(), source, od_config) - - assert adapter.__class__.__name__ == "QwenImageGGUFAdapter" - - class _ConfigAwareModel(nn.Module): def __init__(self, *, od_config): super().__init__() @@ -186,7 +164,6 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch): 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: 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")) diff --git a/tests/diffusion/model_loader/test_diffusers_loader_gguf.py b/tests/diffusion/model_loader/test_diffusers_loader_gguf.py deleted file mode 100644 index f747dca81..000000000 --- a/tests/diffusion/model_loader/test_diffusers_loader_gguf.py +++ /dev/null @@ -1,259 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for GGUF-specific DiffusersPipelineLoader behavior.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -import torch -import torch.nn as nn - -import vllm_omni.diffusion.model_loader.diffusers_loader as loader_module -from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -class _DummyModel(nn.Module): - def __init__(self) -> None: - super().__init__() - self.transformer = nn.Linear(2, 2, bias=True) - self.vae = nn.Linear(2, 2, bias=False) - self.register_buffer("transformer_buffer", torch.ones(1)) - self.calls: list[list[str]] = [] - self.weights_sources = [ - DiffusersPipelineLoader.ComponentSource( - model_or_path="dummy", - subfolder="transformer", - revision=None, - prefix="transformer.", - fall_back_to_pt=True, - ), - DiffusersPipelineLoader.ComponentSource( - model_or_path="dummy", - subfolder="vae", - revision=None, - prefix="vae.", - fall_back_to_pt=True, - ), - ] - - def load_weights(self, weights): - loadable = dict(self.named_parameters()) - loadable.update(dict(self.named_buffers())) - seen: list[str] = [] - loaded: set[str] = set() - for name, tensor in weights: - seen.append(name) - if name in loadable: - target = loadable[name] - target.data.copy_(tensor.to(dtype=target.dtype)) - loaded.add(name) - self.calls.append(seen) - return loaded - - -def _make_loader() -> DiffusersPipelineLoader: - loader = object.__new__(DiffusersPipelineLoader) - loader.load_config = SimpleNamespace( - download_dir="cache-dir", - 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 - - -def test_is_gguf_quantization_accepts_dict_config(): - loader = _make_loader() - loader.quant_config = { - "method": "gguf", - "gguf_model": "weights.gguf", - } - - assert loader._is_gguf_quantization() is True - - -def test_is_gguf_quantization_rejects_non_gguf_dict_config(): - loader = _make_loader() - loader.quant_config = { - "method": "fp8", - "gguf_model": "weights.gguf", - } - - assert loader._is_gguf_quantization() is False - - -def test_is_gguf_quantization_requires_gguf_model_for_dict_config(): - loader = _make_loader() - loader.quant_config = {"method": "gguf"} - - with pytest.raises(ValueError, match="gguf_model"): - loader._is_gguf_quantization() - - -def test_is_gguf_quantization_accepts_object_config(): - loader = _make_loader() - loader.quant_config = SimpleNamespace( - get_name=lambda: "gguf", - gguf_model="weights.gguf", - ) - - assert loader._is_gguf_quantization() is True - - -def test_is_gguf_quantization_uses_fallback_object_without_get_name(): - loader = _make_loader() - loader.quant_config = SimpleNamespace(gguf_model="weights.gguf") - - assert loader._is_gguf_quantization() is True - - -def test_get_model_loadable_names_collects_parameters_and_buffers(): - loader = _make_loader() - model = _DummyModel() - - names = loader._get_model_loadable_names(model) - - assert "transformer.weight" in names - assert "transformer.bias" in names - assert "transformer_buffer" in names - - -def test_resolve_gguf_model_path_returns_local_file(tmp_path): - loader = _make_loader() - gguf_file = tmp_path / "model.gguf" - gguf_file.write_bytes(b"gguf") - - resolved = loader._resolve_gguf_model_path(str(gguf_file), revision=None) - - assert resolved == str(gguf_file) - - -def test_resolve_gguf_model_path_downloads_explicit_gguf_filename(monkeypatch: pytest.MonkeyPatch): - loader = _make_loader() - - monkeypatch.setattr(loader_module.os.path, "isfile", lambda _path: False) - monkeypatch.setattr( - loader_module, - "hf_hub_download", - lambda repo_id, filename, revision, cache_dir: f"{cache_dir}/{repo_id}/{filename}", - ) - - resolved = loader._resolve_gguf_model_path("owner/repo/model-Q4_0.gguf", revision="main") - - assert resolved == "cache-dir/owner/repo/model-Q4_0.gguf" - - -def test_resolve_gguf_model_path_downloads_by_quant_type(monkeypatch: pytest.MonkeyPatch): - loader = _make_loader() - - monkeypatch.setattr(loader_module.os.path, "isfile", lambda _path: False) - monkeypatch.setattr( - loader_module, - "download_gguf", - lambda repo_id, quant_type, cache_dir, revision, ignore_patterns: f"{cache_dir}/{repo_id}/{quant_type}.gguf", - ) - - resolved = loader._resolve_gguf_model_path("owner/repo:Q4_0", revision="main") - - assert resolved == "cache-dir/owner/repo/Q4_0.gguf" - - -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.", - ) - - class _Adapter: - def weights_iterator(self): - yield "weight", torch.ones((2, 2)) - yield "bias", torch.zeros(2) - - 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())) - - assert weights[0][0] == "transformer.weight" - assert torch.equal(weights[0][1], torch.ones((2, 2))) - assert weights[1][0] == "transformer.bias" - assert torch.equal(weights[1][1], torch.zeros(2)) - - -def test_load_weights_with_gguf_falls_back_only_for_missing_transformer_weights(monkeypatch: pytest.MonkeyPatch): - loader = _make_loader() - model = _DummyModel() - - monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources)) - monkeypatch.setattr( - loader, - "_get_gguf_weights_iterator", - lambda source, model: iter([("transformer.weight", torch.ones((2, 2), dtype=torch.float32))]), - ) - - def _hf_iter(source): - if source.subfolder == "transformer": - return iter([("transformer.bias", torch.zeros(2, dtype=torch.float32))]) - return iter([("vae.weight", torch.full((2, 2), 2.0, dtype=torch.float32))]) - - monkeypatch.setattr(loader, "_get_weights_iterator", _hf_iter) - monkeypatch.setattr( - loader, - "_get_expected_parameter_names", - lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"}, - ) - - loaded = loader._load_weights_with_gguf(model) - - assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"} - assert model.calls[0] == ["transformer.weight"] - assert model.calls[1] == ["transformer.bias"] - assert model.calls[2] == ["vae.weight"] - - -def test_load_weights_with_gguf_skips_transformer_fallback_when_gguf_is_complete(monkeypatch: pytest.MonkeyPatch): - loader = _make_loader() - model = _DummyModel() - - monkeypatch.setattr(loader, "_get_weight_sources", lambda _model: tuple(model.weights_sources)) - monkeypatch.setattr( - loader, - "_get_gguf_weights_iterator", - lambda source, model: iter( - [ - ("transformer.weight", torch.ones((2, 2), dtype=torch.float32)), - ("transformer.bias", torch.zeros(2, dtype=torch.float32)), - ] - ), - ) - - hf_calls: list[str] = [] - - def _hf_iter(source): - hf_calls.append(source.subfolder or "") - if source.subfolder == "transformer": - return iter([("transformer.bias", torch.zeros(2, dtype=torch.float32))]) - return iter([("vae.weight", torch.full((2, 2), 2.0, dtype=torch.float32))]) - - monkeypatch.setattr(loader, "_get_weights_iterator", _hf_iter) - monkeypatch.setattr( - loader, - "_get_expected_parameter_names", - lambda _model: {"transformer.weight", "transformer.bias", "vae.weight"}, - ) - - loaded = loader._load_weights_with_gguf(model) - - assert loaded == {"transformer.weight", "transformer.bias", "vae.weight"} - assert hf_calls == ["vae"] diff --git a/tests/diffusion/quantization/test_fp8_config.py b/tests/diffusion/quantization/test_fp8_config.py index a1f79030e..9bad16792 100644 --- a/tests/diffusion/quantization/test_fp8_config.py +++ b/tests/diffusion/quantization/test_fp8_config.py @@ -154,21 +154,6 @@ def test_component_config_with_default(): assert config.resolve("vae.encoder") is None -def test_gguf_config(): - from vllm_omni.quantization import build_quant_config - from vllm_omni.quantization.gguf_config import DiffusionGGUFConfig - - config = build_quant_config( - { - "method": "gguf", - "gguf_model": "path/to/model.gguf", - } - ) - assert isinstance(config, DiffusionGGUFConfig) - assert config.gguf_model == "path/to/model.gguf" - assert config.get_name() == "gguf" - - def test_integration_string(): from vllm_omni.diffusion.data import OmniDiffusionConfig @@ -236,7 +221,7 @@ def test_transformer_config_auto_detects_modelopt_fp8(): def test_supported_methods_includes_vllm(): from vllm_omni.quantization import SUPPORTED_QUANTIZATION_METHODS - for method in ["fp8", "gguf", "awq", "gptq", "bitsandbytes", "modelopt"]: + for method in ["fp8", "awq", "gptq", "bitsandbytes", "modelopt"]: assert method in SUPPORTED_QUANTIZATION_METHODS, f"{method} missing" diff --git a/tests/diffusion/quantization/test_gguf_config.py b/tests/diffusion/quantization/test_gguf_config.py deleted file mode 100644 index 201bcdaba..000000000 --- a/tests/diffusion/quantization/test_gguf_config.py +++ /dev/null @@ -1,128 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for diffusion GGUF quantization config and linear method.""" - -from __future__ import annotations - -from types import SimpleNamespace - -import pytest -import torch -from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod - -from vllm_omni.quantization import build_quant_config -from vllm_omni.quantization.gguf_config import ( - UNQUANTIZED_TYPES, - DiffusionGGUFConfig, - DiffusionGGUFLinearMethod, - dequant_gemm_gguf, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - -def test_gguf_config_creation_and_delegation(): - config = DiffusionGGUFConfig( - gguf_model="weights.gguf", - unquantized_modules=["proj_out"], - ) - - assert config.gguf_model == "weights.gguf" - assert config.unquantized_modules == ["proj_out"] - assert config.get_name() == "gguf" - - -def test_build_quant_config_builds_gguf_config(): - config = build_quant_config( - "gguf", - gguf_model="repo/model-Q4_0.gguf", - unquantized_modules=["norm_out"], - ) - - assert isinstance(config, DiffusionGGUFConfig) - assert config.gguf_model == "repo/model-Q4_0.gguf" - assert config.unquantized_modules == ["norm_out"] - - -def test_gguf_vllm_config_returns_diffusion_linear_method_for_linear_layers(): - linear = object.__new__(LinearBase) - method = DiffusionGGUFConfig(unquantized_modules=[]).get_quant_method(linear, "transformer.img_in") - - assert isinstance(method, DiffusionGGUFLinearMethod) - - -def test_gguf_vllm_config_respects_unquantized_modules(): - linear = object.__new__(LinearBase) - method = DiffusionGGUFConfig(unquantized_modules=["proj_out"]).get_quant_method(linear, "transformer.proj_out") - - assert isinstance(method, UnquantizedLinearMethod) - - -def test_gguf_vllm_config_returns_none_for_non_linear_layers(): - method = DiffusionGGUFConfig(unquantized_modules=[]).get_quant_method(torch.nn.LayerNorm(4), "norm") - - assert method is None - - -def test_dequant_gemm_gguf_uses_plain_matmul_for_unquantized_types(): - qweight_type = next(iter(UNQUANTIZED_TYPES)) - x = torch.tensor([[1.0, 2.0]], dtype=torch.float32) - qweight = torch.tensor([[3.0, 4.0], [5.0, 6.0]], dtype=torch.float32) - - out = dequant_gemm_gguf(x, qweight, qweight_type) - - assert torch.allclose(out, x @ qweight.T) - - -def test_diffusion_gguf_linear_method_applies_bias_on_unquantized_weight(): - qweight_type = next(iter(UNQUANTIZED_TYPES)) - method = DiffusionGGUFLinearMethod(DiffusionGGUFConfig(unquantized_modules=[])) - layer = SimpleNamespace( - qweight=torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32), - qweight_type=SimpleNamespace(weight_type=qweight_type), - ) - x = torch.tensor([[[1.0, 0.5]]], dtype=torch.float32) - bias = torch.tensor([0.25, -0.5], dtype=torch.float32) - - out = method.apply(layer, x, bias) - - expected = x @ layer.qweight.T + bias - assert torch.allclose(out, expected) - - -def test_diffusion_gguf_linear_method_concatenates_sharded_outputs(): - qweight_type = next(iter(UNQUANTIZED_TYPES)) - method = DiffusionGGUFLinearMethod(DiffusionGGUFConfig(unquantized_modules=[])) - qweight = torch.nn.Parameter( - torch.tensor( - [ - [1.0, 0.0], - [0.0, 1.0], - [2.0, 0.0], - [0.0, 2.0], - ], - dtype=torch.float32, - ), - requires_grad=False, - ) - qweight.shard_id = ["left", "right"] - qweight.shard_offset_map = { - "left": (0, 2, 2), - "right": (2, 4, 2), - } - layer = SimpleNamespace( - qweight=qweight, - qweight_type=SimpleNamespace( - shard_weight_type={ - "left": qweight_type, - "right": qweight_type, - } - ), - ) - x = torch.tensor([[1.5, 2.0]], dtype=torch.float32) - - out = method.apply(layer, x) - - expected_q = x @ qweight[:2].T - expected_k = x @ qweight[2:4].T - assert torch.allclose(out, torch.cat([expected_q, expected_k], dim=-1)) diff --git a/tests/diffusion/quantization/test_trajectory_similarity_tool.py b/tests/diffusion/quantization/test_trajectory_similarity_tool.py index 45d51d356..252e6f1c1 100644 --- a/tests/diffusion/quantization/test_trajectory_similarity_tool.py +++ b/tests/diffusion/quantization/test_trajectory_similarity_tool.py @@ -99,7 +99,6 @@ def test_candidate_model_can_point_to_offline_checkpoint_without_online_quantiza candidate_quantization_config_json=None, candidate_ignored_layers=None, ignored_layers=None, - candidate_gguf_model=None, candidate_load_format="default", ) diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index 1f18d975b..3d29a4332 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -359,3 +359,15 @@ def test_resolve_stage_configs_injects_additional_config_into_diffusion_stage(mo assert not hasattr(stage_configs[0].engine_args, "additional_config") assert stage_configs[1].engine_args.additional_config == {"torchair_graph_config": {"enabled": True}} + + +def test_default_stage_config_includes_quantization_config(): + """Ensure structured quantization_config survives default diffusion-stage creation.""" + quantization_config = { + "method": "example_quant", + "weights": "weights.bin", + } + + stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg({"quantization_config": quantization_config})[0] + + assert stage_cfg["engine_args"]["quantization_config"] == quantization_config diff --git a/tests/plugin/__init__.py b/tests/plugin/__init__.py new file mode 100644 index 000000000..988131360 --- /dev/null +++ b/tests/plugin/__init__.py @@ -0,0 +1 @@ +# SPDX-License-Identifier: Apache-2.0 diff --git a/tests/plugin/test_gguf_diffusion.py b/tests/plugin/test_gguf_diffusion.py new file mode 100644 index 000000000..44de4a1fb --- /dev/null +++ b/tests/plugin/test_gguf_diffusion.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""End-to-end tests for GGUF quantization on diffusion models. + +Validates that GGUF-quantized diffusion models generate valid images and +use less peak GPU memory than BF16 baseline. + +Requires vllm-omni to be installed alongside the plugin. + +Usage: + pytest tests/diffusion/test_gguf_memory.py -v +""" + +from __future__ import annotations + +from typing import NamedTuple + +import numpy as np +import pytest +import torch + +from tests.helpers.env import DeviceMemoryMonitor +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner +from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.outputs import OmniRequestOutput +from vllm_omni.platforms import current_omni_platform + + +class DiffusionGGUFTestConfig(NamedTuple): + artifact_prefix: str + gguf_model: str + hf_model: str + min_cosine_similarity: float = 0.95 + + +Z_IMAGE_CONFIG = DiffusionGGUFTestConfig( + artifact_prefix="zimage", + hf_model="Tongyi-MAI/Z-Image-Turbo", + gguf_model="unsloth/Z-Image-Turbo-GGUF:Q4_0", +) + + +FLUX_CONFIG = DiffusionGGUFTestConfig( + artifact_prefix="flux2_klein", + hf_model="black-forest-labs/FLUX.2-klein-4B", + gguf_model="unsloth/FLUX.2-klein-4B-GGUF:Q8_0", +) + + +def _image_cosine_similarity(hf_image, gguf_image) -> float: + hf_tensor = torch.as_tensor(np.array(hf_image.convert("RGB"), copy=True), dtype=torch.float32).flatten() + gguf_tensor = torch.as_tensor(np.array(gguf_image.convert("RGB"), copy=True), dtype=torch.float32).flatten() + return torch.nn.functional.cosine_similarity(hf_tensor, gguf_tensor, dim=0).item() + + +def _generate_single_stage_image( + model: str, + height: int = 256, + width: int = 256, + num_inference_steps: int = 20, + seed: int = 42, + **extra_kwargs, +) -> tuple[list, float]: + """Generate an image with a single-stage diffusion model. + + Returns (images, peak_memory_gib). + """ + omni_kwargs = dict(extra_kwargs) + + memory_monitor = DeviceMemoryMonitor(device_index=0) + memory_monitor.start() + try: + with OmniRunner(model, **omni_kwargs) as runner: + generator = torch.Generator( + device=current_omni_platform.device_type, + ).manual_seed(seed) + outputs = runner.omni.generate( + "a photo of a cat sitting on a laptop keyboard", + OmniDiffusionSamplingParams( + height=height, + width=width, + num_inference_steps=num_inference_steps, + guidance_scale=0.0, + generator=generator, + ), + ) + finally: + memory_monitor.stop() + peak_mem = memory_monitor.peak_used_mb / 1024 + + first_output = outputs[0] + assert first_output.final_output_type == "image" + if hasattr(first_output, "images") and first_output.images: + images = first_output.images + else: + assert hasattr(first_output, "request_output") and first_output.request_output + request_output = first_output.request_output + if isinstance(request_output, list): + req_out = request_output[0] + else: + req_out = request_output + assert isinstance(req_out, OmniRequestOutput) and hasattr(req_out, "images") + images = req_out.images + assert len(images) >= 1 + assert images[0].width == width + assert images[0].height == height + + return images, peak_mem + + +@hardware_test(res={"cuda": "L4"}) +@pytest.mark.full_model +@pytest.mark.diffusion +@pytest.mark.slow +@pytest.mark.parametrize("model", [Z_IMAGE_CONFIG, FLUX_CONFIG], ids=["Z-Image-Turbo", "FLUX.2-klein"]) +def test_single_stage_diffusion_gguf(model: DiffusionGGUFTestConfig, monkeypatch: pytest.MonkeyPatch) -> None: + """Z-Image-Turbo GGUF generates valid images and uses less memory than BF16.""" + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn") + + # BF16 baseline + hf_images, mem_bf16 = _generate_single_stage_image( + model=model.hf_model, + ) + + # GGUF + images, mem_gguf = _generate_single_stage_image( + model=model.hf_model, + diffusion_quantization_config={ + "method": "gguf", + "gguf_model": model.gguf_model, + }, + ) + + hf_image_path = f"test_{model.artifact_prefix}_hf.png" + gguf_image_path = f"test_{model.artifact_prefix}_gguf.png" + assert len(hf_images) >= 1 + hf_images[0].save(hf_image_path) + assert len(images) >= 1 + images[0].save(gguf_image_path) + print(f"Saved HF image: {hf_image_path}") + print(f"Saved GGUF image: {gguf_image_path}") + + image_cosine_similarity = _image_cosine_similarity(hf_images[0], images[0]) + print(f"{model.artifact_prefix} image cosine similarity: {image_cosine_similarity:.4f}") + assert image_cosine_similarity >= model.min_cosine_similarity, ( + f"GGUF image cosine similarity ({image_cosine_similarity:.4f}) should be >= {model.min_cosine_similarity:.4f}" + ) + + print(f"{model.artifact_prefix} BF16 peak VRAM delta: {mem_bf16:.2f} GiB") + print(f"{model.artifact_prefix} GGUF peak VRAM delta: {mem_gguf:.2f} GiB") + reduction = (mem_bf16 - mem_gguf) / mem_bf16 * 100 + print(f"VRAM reduction: {reduction:.1f}%") + assert mem_gguf < mem_bf16, f"GGUF ({mem_gguf:.2f} GiB) should use less VRAM than BF16 ({mem_bf16:.2f} GiB)" diff --git a/tests/test_version.py b/tests/test_version.py index 07e622a7d..911a79611 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -56,3 +56,26 @@ def test_warning_contains_version_strings(): msg = str(record[0].message) assert "0.19.0rc2.dev21" in msg assert "0.18.0" in msg + + +def test_omni_plugin_loader_invokes_vllm_general_plugins_first(monkeypatch): + import vllm.plugins as vllm_plugins + + import vllm_omni.plugins as omni_plugins + + calls = [] + monkeypatch.setattr(omni_plugins, "omni_plugins_loaded", False) + monkeypatch.setattr(vllm_plugins, "load_general_plugins", lambda: calls.append("vllm")) + monkeypatch.setattr( + omni_plugins, + "load_omni_plugins_by_group", + lambda group: {"omni": lambda: calls.append(group)}, + ) + + omni_plugins.load_omni_general_plugins() + + assert calls == ["vllm", omni_plugins.OMNI_DEFAULT_PLUGINS_GROUP] + + omni_plugins.load_omni_general_plugins() + + assert calls == ["vllm", omni_plugins.OMNI_DEFAULT_PLUGINS_GROUP] diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index c44bd3ea9..dc5f4ef9c 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -551,7 +551,7 @@ class _DiffusionConfigProjection: elif isinstance(self.quantization_config, str): self.quantization_config = build_quant_config(self.quantization_config) elif isinstance(self.quantization_config, Mapping): - self.quantization_config = build_quant_config(dict(self.quantization_config)) + self.quantization_config = dict(self.quantization_config) else: raise TypeError( "quantization_config must be str, dict, QuantizationConfig, or None, " diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index 7ee2b4395..943c55a52 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -11,7 +11,6 @@ from pathlib import Path from typing import cast import torch -from huggingface_hub import hf_hub_download from torch import nn from vllm.config import ModelConfig from vllm.config.load import LoadConfig @@ -36,7 +35,6 @@ from vllm_omni.diffusion.distributed.hsdp import HSDPInferenceConfig, apply_hsdp from vllm_omni.diffusion.model_loader.checkpoint_adapters import ( get_checkpoint_adapter, ) -from vllm_omni.diffusion.model_loader.gguf_adapters import get_gguf_adapter from vllm_omni.diffusion.models.diffusers_adapter.pipeline_diffusers_adapter import DiffusersAdapterPipeline from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery from vllm_omni.diffusion.registry import initialize_model @@ -381,10 +379,7 @@ class DiffusersPipelineLoader: # DiffusionPipeline.from_pretrained() internally; it does # not use our native customized pipeline classes. cast(DiffusersAdapterPipeline, model).load_weights() - 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) # Process weights after loading for quantization (e.g., FP8 online quantization) @@ -454,7 +449,7 @@ class DiffusersPipelineLoader: def _is_expected_quantized_weight(name: str) -> bool: """Return True if *name* is a quantization-specific parameter. - Quantization methods (GPTQ, AWQ, FP8, GGUF, Autoround, etc.) create extra + Quantization methods (GPTQ, AWQ, FP8, Autoround, etc.) create extra parameters that have no counterpart in an unquantized checkpoint. These are expected to be absent and should not trigger a load error. """ @@ -467,8 +462,6 @@ class DiffusersPipelineLoader: ".weight_scale", ".weight_scale_inv", ".input_scale", - # GGUF - ".qweight_type", # INT8 (weight_scale already covered above) ) return name.endswith(_QUANTIZED_WEIGHT_SUFFIXES) @@ -500,116 +493,6 @@ class DiffusersPipelineLoader: if unexpected_missing: raise ValueError(f"Following weights were not initialized from checkpoint: {unexpected_missing}") - 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) - - 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 - - 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 - - if isinstance(self.quant_config, dict): - return False - - return bool(maybe_gguf_model) - - def _is_transformer_source(self, source: "ComponentSource") -> bool: - if source.subfolder == "transformer": - return True - return source.prefix.startswith("transformer.") - - def _get_model_loadable_names(self, model: nn.Module) -> set[str]: - # Avoid model.state_dict() here because GGUF uses UninitializedParameter - # which raises during detach(). Collect names directly. - return {name for name, _ in model.named_parameters()} | {name for name, _ in model.named_buffers()} - - def _resolve_gguf_model_path(self, gguf_model: str, revision: str | None) -> str: - if os.path.isfile(gguf_model): - return gguf_model - # repo_id/filename.gguf - if "/" in gguf_model and gguf_model.endswith(".gguf"): - repo_id, filename = gguf_model.rsplit("/", 1) - return hf_hub_download( - repo_id=repo_id, - filename=filename, - revision=revision, - cache_dir=self.load_config.download_dir, - ) - # repo_id:quant_type - if "/" in gguf_model and ":" in gguf_model: - repo_id, quant_type = gguf_model.rsplit(":", 1) - return download_gguf( - repo_id, - quant_type, - cache_dir=self.load_config.download_dir, - revision=revision, - ignore_patterns=self.load_config.ignore_patterns, - ) - raise ValueError( - f"Unrecognized GGUF reference: {gguf_model!r} (expected local file, " - "/.gguf, or :)" - ) - - def _get_gguf_weights_iterator( - self, - source: "ComponentSource", - model: nn.Module, - ) -> Generator[tuple[str, torch.Tensor], None, 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, 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) -> 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)) - - # GGUF checkpoints can be transformer-only or partially quantized. - # Only fall back to HF if this source still has missing loadable weights. - loadable_names = loadable_names or self._get_model_loadable_names(model) - has_missing_for_source = any( - name.startswith(source.prefix) and name not in loaded for name in loadable_names - ) - if not has_missing_for_source: - continue - - hf_iter = self._get_weights_iterator(source) - hf_iter = ( - (name, tensor) for (name, tensor) in hf_iter if name in loadable_names and name not in loaded - ) - loaded |= model.load_weights(hf_iter) - else: - loaded |= model.load_weights(self._get_weights_iterator(source)) - - weights_to_load = self._get_expected_parameter_names(model) - weights_not_loaded = weights_to_load - loaded - if weights_not_loaded: - raise ValueError(f"Following weights were not initialized from checkpoint: {weights_not_loaded}") - return loaded - def _init_from_load_format( self, load_format: str, diff --git a/vllm_omni/diffusion/model_loader/gguf_adapters/__init__.py b/vllm_omni/diffusion/model_loader/gguf_adapters/__init__.py deleted file mode 100644 index 3266ba06c..000000000 --- a/vllm_omni/diffusion/model_loader/gguf_adapters/__init__.py +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from typing import TYPE_CHECKING - -import torch - -from .base import GGUFAdapter -from .flux2_klein import Flux2KleinGGUFAdapter -from .qwen_image import QwenImageGGUFAdapter -from .z_image import ZImageGGUFAdapter - -if TYPE_CHECKING: - from vllm_omni.diffusion.data import OmniDiffusionConfig - from vllm_omni.diffusion.model_loader.diffusers_loader import ( - DiffusersPipelineLoader, - ) - - -def get_gguf_adapter( - gguf_file: str, - model: torch.nn.Module, - source: DiffusersPipelineLoader.ComponentSource, - od_config: OmniDiffusionConfig, -) -> GGUFAdapter: - adapter_classes = (QwenImageGGUFAdapter, ZImageGGUFAdapter, Flux2KleinGGUFAdapter) - for adapter_cls in adapter_classes: - if adapter_cls.is_compatible(od_config, model, source): - return adapter_cls(gguf_file, model, source, od_config) - model_type = None - if od_config.tf_model_config is not None: - model_type = od_config.tf_model_config.get("model_type") - supported = ", ".join(cls.__name__ for cls in adapter_classes) - raise ValueError( - "No GGUF adapter matched diffusion model " - f"(model_class_name={od_config.model_class_name!r}, model_type={model_type!r}). " - f"Supported adapters: {supported}." - ) - - -__all__ = [ - "GGUFAdapter", - "Flux2KleinGGUFAdapter", - "QwenImageGGUFAdapter", - "ZImageGGUFAdapter", - "get_gguf_adapter", -] diff --git a/vllm_omni/diffusion/model_loader/gguf_adapters/base.py b/vllm_omni/diffusion/model_loader/gguf_adapters/base.py deleted file mode 100644 index f3f4d28a0..000000000 --- a/vllm_omni/diffusion/model_loader/gguf_adapters/base.py +++ /dev/null @@ -1,109 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Generator -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -import numpy as np -import torch - -try: - import gguf -except ImportError: - # Optional dependency: only required for GGUF-quantized diffusion models. - # Keep this import lazy so a missing ``gguf`` does not break importing the - # diffusion model loader (and thus inspection/loading of every other - # diffusion model that imports through this package). - gguf = None - -if TYPE_CHECKING: - from vllm_omni.diffusion.data import OmniDiffusionConfig - from vllm_omni.diffusion.model_loader.diffusers_loader import ( - DiffusersPipelineLoader, - ) - - -@dataclass -class MappedTensor: - name: str - tensor: Any - tensor_type: Any - row_slice: slice | None = None - swap_scale_shift: bool = False - - -class GGUFAdapter(ABC): - """Base class for model-specific GGUF adapters.""" - - def __init__( - self, - gguf_file: str, - model: torch.nn.Module, - source: DiffusersPipelineLoader.ComponentSource, - od_config: OmniDiffusionConfig, - ) -> None: - self.gguf_file = gguf_file - self.model = model - self.source = source - self.od_config = od_config - - @staticmethod - def is_compatible( - od_config: OmniDiffusionConfig, - model: torch.nn.Module, - source: DiffusersPipelineLoader.ComponentSource, - ) -> bool: - return False - - @abstractmethod - def weights_iterator(self) -> Generator[tuple[str, torch.Tensor], None, None]: - raise NotImplementedError - - -# FIXME(Isotr0py): Sync implemnentation with upstream vLLM? -def gguf_quant_weights_iterator(gguf_file: str) -> Generator[tuple[str, torch.Tensor]]: - """ - Iterate over the quant weights in the model gguf files and convert - them to torch tensors. - Be careful of the order of yielding weight types and weights data, - we have to yield all weight types first before yielding any weights. - Otherwise it would cause issue when loading weights with for packed - layer with different quant types. - """ - - if gguf is None: - raise ImportError( - "The 'gguf' package is required to load GGUF-quantized diffusion models. " - "Install it with `pip install gguf`." - ) - reader = gguf.GGUFReader(gguf_file) - - for tensor in reader.tensors: - weight_type = tensor.tensor_type - name = tensor.name - - if weight_type.name not in ("F32", "F16"): - weight_type_name = name.replace("weight", "qweight_type") - weight_type = torch.tensor(weight_type) - yield weight_type_name, weight_type - - for tensor in reader.tensors: - weight = tensor.data - weight_type = tensor.tensor_type - name = tensor.name - if weight_type.name not in ("F32", "F16"): - name = name.replace("weight", "qweight") - if weight_type.name == "BF16" and tensor.data.dtype == np.uint8: - # BF16 is currently the only "quantization" type that isn't - # actually quantized but is read as a raw byte tensor. - # Reinterpret as `torch.bfloat16` tensor. - weight = weight.view(np.uint16) - if reader.byte_order == "S": - # GGUF endianness != system endianness - weight = weight.byteswap() - param = torch.tensor(weight).view(torch.bfloat16) - else: - param = torch.tensor(weight) - yield name, param diff --git a/vllm_omni/diffusion/model_loader/gguf_adapters/flux2_klein.py b/vllm_omni/diffusion/model_loader/gguf_adapters/flux2_klein.py deleted file mode 100644 index 4ee6439b2..000000000 --- a/vllm_omni/diffusion/model_loader/gguf_adapters/flux2_klein.py +++ /dev/null @@ -1,98 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from collections.abc import Iterable - -import torch -from vllm.model_executor.models.utils import WeightsMapper - -from .base import GGUFAdapter, gguf_quant_weights_iterator - -FLUX2_TRANSFORMER_KEYS_RENAME_DICT = { - "single_blocks.": "single_transformer_blocks.", - # Image and text input projections - "img_in": "x_embedder", - "txt_in": "context_embedder", - # Timestep and guidance embeddings - "time_in.in_layer": "time_guidance_embed.timestep_embedder.linear_1", - "time_in.out_layer": "time_guidance_embed.timestep_embedder.linear_2", - "guidance_in.in_layer": "time_guidance_embed.guidance_embedder.linear_1", - "guidance_in.out_layer": "time_guidance_embed.guidance_embedder.linear_2", - # Modulation parameters - "double_stream_modulation_img.lin": "double_stream_modulation_img.linear", - "double_stream_modulation_txt.lin": "double_stream_modulation_txt.linear", - "single_stream_modulation.lin": "single_stream_modulation.linear", - # Final output layer - # "final_layer.adaLN_modulation.1": "norm_out.linear", # Handle separately since we need to swap mod params - "final_layer.linear": "proj_out", -} - -FLUX2_TRANSFORMER_ADA_LAYER_NORM_KEY_MAP = { - "final_layer.adaLN_modulation.1": "norm_out.linear", -} - -FLUX2_TRANSFORMER_DOUBLE_BLOCK_KEY_MAP = { - "double_blocks.": "transformer_blocks.", - # Handle fused QKV projections separately as we need to break into Q, K, V projections - "img_attn.norm.query_norm": "attn.norm_q", - "img_attn.norm.key_norm": "attn.norm_k", - "img_attn.proj": "attn.to_out.0", - "img_mlp.0": "ff.linear_in", - "img_mlp.2": "ff.linear_out", - "txt_attn.norm.query_norm": "attn.norm_added_q", - "txt_attn.norm.key_norm": "attn.norm_added_k", - "txt_attn.proj": "attn.to_add_out", - "txt_mlp.0": "ff_context.linear_in", - "txt_mlp.2": "ff_context.linear_out", - # Additional for fuse qkv - "img_attn.qkv": "attn.to_qkv", - "txt_attn.qkv": "attn.add_kv_proj", -} - -FLUX2_TRANSFORMER_SINGLE_BLOCK_KEY_MAP = { - "linear1": "attn.to_qkv_mlp_proj", - "linear2": "attn.to_out", - "norm.query_norm": "attn.norm_q", - "norm.key_norm": "attn.norm_k", -} - - -class Flux2KleinGGUFAdapter(GGUFAdapter): - """GGUF adapter for Flux2-Klein models with qkv splitting and adaLN swap.""" - - @staticmethod - def is_compatible(od_config, model: torch.nn.Module, source) -> bool: - model_class = od_config.model_class_name or "" - if model_class.startswith("Flux2"): - return True - cfg = od_config.tf_model_config - if cfg is not None: - model_type = str(cfg.get("model_type", "")).lower() - if model_type.startswith("flux"): - return True - return False - - gguf_to_hf_mapper = WeightsMapper( - # double_stream_modulation - orig_to_new_prefix=FLUX2_TRANSFORMER_KEYS_RENAME_DICT | FLUX2_TRANSFORMER_ADA_LAYER_NORM_KEY_MAP, - orig_to_new_substr=FLUX2_TRANSFORMER_DOUBLE_BLOCK_KEY_MAP | FLUX2_TRANSFORMER_SINGLE_BLOCK_KEY_MAP, - ) - - def weights_iterator(self) -> Iterable[tuple[str, torch.Tensor]]: - def custom_weights_adapter( - weights: Iterable[tuple[str, torch.Tensor]], - ) -> Iterable[tuple[str, torch.Tensor]]: - for name, weight in weights: - # Handle the special case for adaLN modulation parameters that require swapping shift and scale - if name.endswith(".scale"): - name = name.replace(".scale", ".weight") - if name == "norm_out.linear.weight": - shift, scale = weight.chunk(2, dim=0) - weight = torch.cat([scale, shift], dim=0) - yield name, weight - else: - yield name, weight - - weights = gguf_quant_weights_iterator(self.gguf_file) - weights = self.gguf_to_hf_mapper.apply(weights) - yield from custom_weights_adapter(weights) diff --git a/vllm_omni/diffusion/model_loader/gguf_adapters/qwen_image.py b/vllm_omni/diffusion/model_loader/gguf_adapters/qwen_image.py deleted file mode 100644 index cc8ccea5c..000000000 --- a/vllm_omni/diffusion/model_loader/gguf_adapters/qwen_image.py +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from collections.abc import Iterable - -import torch - -from .base import GGUFAdapter, gguf_quant_weights_iterator - - -class QwenImageGGUFAdapter(GGUFAdapter): - """GGUF adapter for the Qwen-Image transformer family.""" - - @staticmethod - def is_compatible(od_config, model: torch.nn.Module, source) -> bool: - model_class = od_config.model_class_name or "" - if model_class.startswith("QwenImage"): - return True - cfg = od_config.tf_model_config - if cfg is not None: - model_type = str(cfg.get("model_type", "")).lower() - if model_type.startswith("qwen_image"): - return True - return False - - def weights_iterator(self) -> Iterable[tuple[str, torch.Tensor]]: - yield from gguf_quant_weights_iterator(self.gguf_file) diff --git a/vllm_omni/diffusion/model_loader/gguf_adapters/z_image.py b/vllm_omni/diffusion/model_loader/gguf_adapters/z_image.py deleted file mode 100644 index 7d8963355..000000000 --- a/vllm_omni/diffusion/model_loader/gguf_adapters/z_image.py +++ /dev/null @@ -1,43 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -from __future__ import annotations - -from collections.abc import Iterable - -import torch -from vllm.model_executor.models.utils import WeightsMapper - -from .base import GGUFAdapter, gguf_quant_weights_iterator - -Z_IMAGE_KEYS_RENAME_DICT = { - "final_layer.": "all_final_layer.2-1.", - "x_embedder.": "all_x_embedder.2-1.", - ".attention.qkv": ".attention.to_qkv", - ".attention.k_norm": ".attention.norm_k", - ".attention.q_norm": ".attention.norm_q", - ".attention.out": ".attention.to_out.0", - "model.diffusion_model.": "", -} - - -class ZImageGGUFAdapter(GGUFAdapter): - """GGUF adapter for Z-Image models with QKV/FFN shard support.""" - - @staticmethod - def is_compatible(od_config, model: torch.nn.Module, source) -> bool: - model_class = od_config.model_class_name or "" - if model_class.startswith("ZImage"): - return True - cfg = od_config.tf_model_config - if cfg is not None: - model_type = str(cfg.get("model_type", "")).lower() - if model_type in {"z_image", "zimage", "z-image"}: - return True - return False - - gguf_to_hf_mapper = WeightsMapper( - orig_to_new_substr=Z_IMAGE_KEYS_RENAME_DICT, - ) - - def weights_iterator(self) -> Iterable[tuple[str, torch.Tensor]]: - weights = gguf_quant_weights_iterator(self.gguf_file) - yield from self.gguf_to_hf_mapper.apply(weights) diff --git a/vllm_omni/diffusion/models/flux2/flux2_transformer.py b/vllm_omni/diffusion/models/flux2/flux2_transformer.py index 5a65db257..52e0a4430 100644 --- a/vllm_omni/diffusion/models/flux2/flux2_transformer.py +++ b/vllm_omni/diffusion/models/flux2/flux2_transformer.py @@ -1034,7 +1034,7 @@ class Flux2Transformer2DModel(nn.Module): name = original_name if name not in params_dict and ".to_out.0." in name: name = name.replace(".to_out.0.", ".to_out.") - # Some GGUF checkpoints include quantized tensors for modules that + # Some quantized checkpoints include tensors for modules that # are intentionally left unquantized in this model. param = params_dict.get(name) if param is None: diff --git a/vllm_omni/diffusion/models/flux2_klein/flux2_klein_transformer.py b/vllm_omni/diffusion/models/flux2_klein/flux2_klein_transformer.py index cf2c9f78a..e62fccba0 100644 --- a/vllm_omni/diffusion/models/flux2_klein/flux2_klein_transformer.py +++ b/vllm_omni/diffusion/models/flux2_klein/flux2_klein_transformer.py @@ -1044,7 +1044,7 @@ class Flux2Transformer2DModel(nn.Module): name = original_name if name not in params_dict and ".to_out.0." in name: name = name.replace(".to_out.0.", ".to_out.") - # Some GGUF checkpoints include quantized tensors for modules that + # Some quantized checkpoints include tensors for modules that # are intentionally left unquantized in this model. param = params_dict.get(name) if param is None: diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index e71af65d3..ec9a9698c 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -1043,6 +1043,7 @@ class AsyncOmniEngine: "enable_multithread_weight_load": kwargs.get("enable_multithread_weight_load", True), "num_weight_load_threads": kwargs.get("num_weight_load_threads", 4), "quantization": kwargs.get("quantization", None), + "quantization_config": kwargs.get("quantization_config", None), "diffusion_kv_cache_dtype": kwargs.get("diffusion_kv_cache_dtype", None), "diffusion_kv_cache_skip_steps": kwargs.get("diffusion_kv_cache_skip_steps", None), "diffusion_kv_cache_skip_layers": kwargs.get("diffusion_kv_cache_skip_layers", None), @@ -1233,7 +1234,7 @@ class AsyncOmniEngine: kwargs.get("diffusion_attention_config"), attention_backend=kwargs.get("diffusion_attention_backend"), ) - quantization_config = kwargs.get("diffusion_quantization_config") + quantization_config = kwargs.get("diffusion_quantization_config") or kwargs.get("quantization_config") if quantization_config is not None: if ( not hasattr(cfg.engine_args, "quantization_config") diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index 86576ea55..5d435bc2f 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -478,7 +478,7 @@ class OmniServeCommand(CLISubcommand): default=None, help=( "JSON string for diffusion quantization_config. " - 'Example: \'{"method":"gguf","gguf_model":"/path/to/model.gguf"}\'.' + 'Example: \'{"method":"fp8","activation_scheme":"dynamic"}\'.' ), ) omni_config_group.add_argument( diff --git a/vllm_omni/plugins/__init__.py b/vllm_omni/plugins/__init__.py index 41bf398ca..240394b2c 100644 --- a/vllm_omni/plugins/__init__.py +++ b/vllm_omni/plugins/__init__.py @@ -68,6 +68,10 @@ def load_omni_general_plugins() -> None: return omni_plugins_loaded = True + from vllm.plugins import load_general_plugins + + load_general_plugins() + plugins = load_omni_plugins_by_group(group=OMNI_DEFAULT_PLUGINS_GROUP) # General plugins: we only need to execute the loaded functions. for func in plugins.values(): diff --git a/vllm_omni/quantization/__init__.py b/vllm_omni/quantization/__init__.py index d8cad3a98..8896903e8 100644 --- a/vllm_omni/quantization/__init__.py +++ b/vllm_omni/quantization/__init__.py @@ -12,10 +12,10 @@ Adds per-component quantization for multi-stage models. """ from .component_config import ComponentQuantizationConfig -from .factory import SUPPORTED_QUANTIZATION_METHODS, build_quant_config +from .factory import SUPPORTED_QUANTIZATION_METHODS, build_quant_config, register_quantization_override from .inc_config import OmniINCConfig -# Heavy configs (GGUF, MXFP8) are NOT imported here to avoid pulling in +# Heavy configs are NOT imported here to avoid pulling in # optional dependencies (pynvml, torch_npu) at module load time. # Import them directly when needed: # from vllm_omni.quantization.mxfp8_config import DiffusionMXFP8Config @@ -25,4 +25,5 @@ __all__ = [ "ComponentQuantizationConfig", "OmniINCConfig", "SUPPORTED_QUANTIZATION_METHODS", + "register_quantization_override", ] diff --git a/vllm_omni/quantization/factory.py b/vllm_omni/quantization/factory.py index d9a7a31bf..2129d8f04 100644 --- a/vllm_omni/quantization/factory.py +++ b/vllm_omni/quantization/factory.py @@ -3,8 +3,8 @@ """Factory for building quantization configs. build_quant_config() delegates to vLLM's quantization registry. -The only extension point is _OVERRIDES for methods that need a -different QuantizationConfig subclass in the OMNI context (e.g. GGUF). +Out-of-tree integrations can register Omni-specific builders with +register_quantization_override(). """ from __future__ import annotations @@ -81,13 +81,6 @@ from .component_config import ComponentQuantizationConfig # noqa: E402 logger = init_logger(__name__) -def _build_gguf(**kw: Any) -> QuantizationConfig: - """Lazy import to avoid pulling in CUDA/pynvml at module load time.""" - from .gguf_config import DiffusionGGUFConfig - - return DiffusionGGUFConfig(**kw) - - def _build_int8(**kw: Any) -> QuantizationConfig: """Lazy import for Int8 diffusion config (supports CUDA + NPU).""" from .int8_config import DiffusionInt8Config @@ -140,7 +133,6 @@ def _build_inc(**kw: Any) -> QuantizationConfig: _OVERRIDES: dict[str, Callable[..., QuantizationConfig]] = { - "gguf": _build_gguf, "int8": _build_int8, "mxfp8": _build_mxfp8, "mxfp4": _build_mxfp4, @@ -150,7 +142,24 @@ _OVERRIDES: dict[str, Callable[..., QuantizationConfig]] = { "auto_round": _build_inc, } -SUPPORTED_QUANTIZATION_METHODS: list[str] = list(dict.fromkeys(QUANTIZATION_METHODS + list(_OVERRIDES.keys()))) + +def _compute_supported_quantization_methods() -> list[str]: + return list(dict.fromkeys(QUANTIZATION_METHODS + list(_OVERRIDES.keys()))) + + +SUPPORTED_QUANTIZATION_METHODS: list[str] = _compute_supported_quantization_methods() + + +def _refresh_registered_methods() -> None: + SUPPORTED_QUANTIZATION_METHODS[:] = _compute_supported_quantization_methods() + global _CACHED_ALIAS_MAP + _CACHED_ALIAS_MAP = None + + +def register_quantization_override(method: str, builder: Callable[..., QuantizationConfig]) -> None: + """Register an Omni-specific quantization config builder.""" + _OVERRIDES[_normalize_method_name(method)] = builder + _refresh_registered_methods() def _build_reverse_alias_map() -> dict[str, str]: diff --git a/vllm_omni/quantization/gguf_config.py b/vllm_omni/quantization/gguf_config.py deleted file mode 100644 index 186ac4ee7..000000000 --- a/vllm_omni/quantization/gguf_config.py +++ /dev/null @@ -1,323 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""GGUF quantization config for diffusion transformers. - -Uses dequant+GEMM instead of the fused kernel path (which expects 2D inputs). - -The GGUF quantization module was migrated to an external plugin in upstream -vLLM (commit 6635279d8). This file inlines the base classes that -DiffusionGGUFConfig / DiffusionGGUFLinearMethod depend on, reproduced from -the last upstream version before the migration. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from types import MappingProxyType -from typing import TYPE_CHECKING, Any - -import gguf -import torch -from gguf import GGMLQuantizationType as WeightType -from torch.nn.parameter import Parameter, UninitializedParameter -from vllm import _custom_ops as ops -from vllm.logger import init_logger -from vllm.model_executor.layers.linear import ( - LinearBase, - LinearMethodBase, - UnquantizedLinearMethod, -) -from vllm.model_executor.layers.quantization.base_config import ( - QuantizationConfig, - QuantizeMethodBase, -) -from vllm.model_executor.utils import set_weight_attrs - -if TYPE_CHECKING: - from vllm.model_executor.layers.quantization import QuantizationMethods - -logger = init_logger(__name__) - -# --------------------------------------------------------------------------- -# GGUF base types (inlined from upstream vllm before migration to plugin) -# --------------------------------------------------------------------------- - -UNQUANTIZED_TYPES: set[WeightType] = {WeightType.F32, WeightType.F16, WeightType.BF16} - -STANDARD_QUANT_TYPES: set[WeightType] = { - WeightType.Q4_0, - WeightType.Q4_1, - WeightType.Q5_0, - WeightType.Q5_1, - WeightType.Q8_0, - WeightType.Q8_1, -} -KQUANT_TYPES: set[WeightType] = { - WeightType.Q2_K, - WeightType.Q3_K, - WeightType.Q4_K, - WeightType.Q5_K, - WeightType.Q6_K, -} -IMATRIX_QUANT_TYPES: set[WeightType] = { - WeightType.IQ1_M, - WeightType.IQ1_S, - WeightType.IQ2_XXS, - WeightType.IQ2_XS, - WeightType.IQ2_S, - WeightType.IQ3_XXS, - WeightType.IQ3_S, - WeightType.IQ4_XS, - WeightType.IQ4_NL, -} -DEQUANT_TYPES: set[WeightType] = STANDARD_QUANT_TYPES | KQUANT_TYPES | IMATRIX_QUANT_TYPES - - -def is_layer_skipped_gguf( - prefix: str, - unquantized_modules: list[str], - fused_mapping: Mapping[str, list[str]] = MappingProxyType({}), -) -> bool: - # Fused layers like gate_up_proj or qkv_proj will not be fused - # in the safetensors checkpoint. So, we convert the name - # from the fused version to unfused + check to make sure that - # each shard of the fused layer has the same scheme. - proj_name = prefix.split(".")[-1] - if proj_name in fused_mapping: - shard_prefixes = [prefix.replace(proj_name, shard_proj_name) for shard_proj_name in fused_mapping[proj_name]] - - is_skipped = None - for shard_prefix in shard_prefixes: - is_shard_skipped = any(shard_prefix in module_name for module_name in unquantized_modules) - - if is_skipped is None: - is_skipped = is_shard_skipped - elif is_shard_skipped != is_skipped: - raise ValueError( - f"Detected some but not all shards of {prefix} " - "are quantized. All shards of fused layers " - "to have the same precision." - ) - else: - is_skipped = any(module_name in prefix for module_name in unquantized_modules) - - assert is_skipped is not None - return is_skipped - - -class _GGUFUninitializedParameter(UninitializedParameter): - """UninitializedParameter subclass that stores GGUF weight data in a list. - - The actual tensor is materialized later via _create_padded_weight_param. - """ - - cls_to_become = Parameter - data_container: list[torch.Tensor] - - -class GGUFConfig(QuantizationConfig): - """Config class for GGUF (inlined from upstream vllm).""" - - def __init__(self, unquantized_modules: list[str] | None = None) -> None: - super().__init__() - self.unquantized_modules = unquantized_modules or [] - - def __repr__(self) -> str: - return "GGUFConfig()" - - def get_name(self) -> QuantizationMethods: - return "gguf" - - def get_supported_act_dtypes(self) -> list[torch.dtype]: - return [torch.half, torch.bfloat16, torch.float32] - - @classmethod - def get_min_capability(cls) -> int: - return 60 - - @classmethod - def get_config_filenames(cls) -> list[str]: - return [] # no extra configs. - - @classmethod - def from_config(cls, config: dict[str, Any]) -> GGUFConfig: - return cls() - - @classmethod - def override_quantization_method( - cls, hf_quant_cfg: dict[str, Any], user_quant: str | None, hf_config=None - ) -> QuantizationMethods | None: - if user_quant == "gguf": - return "gguf" - return None - - def get_quant_method(self, layer: torch.nn.Module, prefix: str) -> QuantizeMethodBase | None: - if isinstance(layer, LinearBase): - if is_layer_skipped_gguf(prefix, self.unquantized_modules, self.packed_modules_mapping): - return UnquantizedLinearMethod() - return GGUFLinearMethod(self) - return None - - -class GGUFLinearMethod(LinearMethodBase): - """Linear method for GGUF (inlined from upstream vllm).""" - - def __init__(self, quant_config: GGUFConfig): - self.quant_config = quant_config - - def create_weights( - self, - layer: torch.nn.Module, - input_size_per_partition: int, - output_partition_sizes: list[int], - input_size: int, - output_size: int, - params_dtype: torch.dtype, - **extra_weight_attrs, - ): - self.params_dtype = params_dtype - output_size_per_partition = sum(output_partition_sizes) - - tensor_shape = (output_size_per_partition, input_size_per_partition) - qweight = _GGUFUninitializedParameter(requires_grad=False) - set_weight_attrs( - qweight, - { - "input_dim": 1, - "output_dim": 0, - "tensor_shape": tensor_shape, - "is_gguf_weight": True, - "data_container": [], - "shard_id": [], - "shard_id_map": {}, - }, - ) - set_weight_attrs(qweight, extra_weight_attrs) - layer.register_parameter("qweight", qweight) - - qweight_type = Parameter( - torch.empty(len(output_partition_sizes), dtype=torch.uint8), - requires_grad=False, - ) - set_weight_attrs( - qweight_type, - { - "is_gguf_weight_type": True, - "weight_type": 0, - "shard_weight_type": {}, - "ignore_warning": True, - }, - ) - set_weight_attrs(qweight_type, extra_weight_attrs) - layer.register_parameter("qweight_type", qweight_type) - - def process_weights_after_loading(self, layer: torch.nn.Module): - qweight_type = layer.qweight_type.weight_type - if not (qweight_type in UNQUANTIZED_TYPES or qweight_type in DEQUANT_TYPES): - qweight_type = WeightType(qweight_type) - raise ValueError(f"Unsupported GGUF quantization type {qweight_type} in layer {layer}.") - self._create_padded_weight_param(layer) - - def _create_padded_weight_param(self, layer: torch.nn.Module): - """Create padded weight parameter for GGUF MergedLinear layer.""" - qweight = layer.qweight - shard_id_map = qweight.shard_id_map - shard_id = qweight.shard_id - if len(data_container := qweight.data_container) > 1: - dtype = {data.dtype for data in data_container} - assert len(dtype) == 1, ValueError(f"Data container has mixed dtypes: {dtype}") - dtype = next(iter(dtype)) - padded_side = max(x.size(1) for x in data_container) - concat_side = sum(x.size(0) for x in data_container) - padded_data = torch.zeros((concat_side, padded_side), dtype=dtype, device=qweight.device) - shard_offset_map = dict[str, tuple[int, int, int]]() - for idx in shard_id: - id_in_container = shard_id_map[idx] - start = sum(x.size(0) for x in data_container[:id_in_container]) - end = start + data_container[id_in_container].size(0) - size = data_container[id_in_container].size(1) - padded_data[start:end, :size] = data_container[id_in_container] - shard_offset_map[idx] = (start, end, size) - qweight.data_container.clear() - padded_param = Parameter(padded_data, requires_grad=False) - set_weight_attrs(padded_param, vars(qweight)) - set_weight_attrs(padded_param, {"shard_offset_map": shard_offset_map}) - layer.register_parameter("qweight", padded_param) - - def apply( - self, - layer: torch.nn.Module, - x: torch.Tensor, - bias: torch.Tensor | None = None, - ) -> torch.Tensor: - # Base apply: overridden by DiffusionGGUFLinearMethod. - raise NotImplementedError("Use DiffusionGGUFLinearMethod for diffusion GGUF") - - -# --------------------------------------------------------------------------- -# Diffusion-specific GGUF (dequant+GEMM, replaces fused kernel path) -# --------------------------------------------------------------------------- - - -def dequant_gemm_gguf(x: torch.Tensor, qweight: torch.Tensor, qweight_type: int) -> torch.Tensor: - if qweight_type in UNQUANTIZED_TYPES: - return x @ qweight.T - block_size, type_size = gguf.GGML_QUANT_SIZES[qweight_type] - shape = (qweight.shape[0], qweight.shape[1] // type_size * block_size) - weight = ops.ggml_dequantize(qweight, qweight_type, *shape, x.dtype) - return x @ weight.T - - -class DiffusionGGUFLinearMethod(GGUFLinearMethod): - """GGUF linear method using dequant+GEMM for N-D diffusion tensors.""" - - def apply( - self, - layer: torch.nn.Module, - x: torch.Tensor, - bias: torch.Tensor | None = None, - ) -> torch.Tensor: - shard_id = getattr(layer.qweight, "shard_id", None) - - if shard_id: - shard_id = ["q", "k", "v"] if "q" in shard_id else shard_id - qweight = layer.qweight - result = [] - for idx in shard_id: - start, end, offset = layer.qweight.shard_offset_map[idx] - qweight_type = layer.qweight_type.shard_weight_type[idx] - result.append( - dequant_gemm_gguf( - x, - qweight[start:end, :offset].contiguous(), - qweight_type, - ) - ) - out = torch.cat(result, axis=-1) - else: - qweight = layer.qweight - qweight_type = layer.qweight_type.weight_type - out = dequant_gemm_gguf(x, qweight, qweight_type) - - if bias is not None: - out.add_(bias) - return out - - -class DiffusionGGUFConfig(GGUFConfig): - """GGUF config that carries gguf_model path and uses dequant+GEMM.""" - - def __init__( - self, - gguf_model: str | None = None, - unquantized_modules: list[str] | None = None, - ) -> None: - super().__init__(unquantized_modules=unquantized_modules or []) - self.gguf_model = gguf_model - - def get_quant_method(self, layer: torch.nn.Module, prefix: str) -> QuantizeMethodBase | None: - if isinstance(layer, LinearBase): - if is_layer_skipped_gguf(prefix, self.unquantized_modules, self.packed_modules_mapping): - return UnquantizedLinearMethod() - return DiffusionGGUFLinearMethod(self) - return None diff --git a/vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py b/vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py index 3d4f68d23..77b191738 100644 --- a/vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py +++ b/vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py @@ -280,7 +280,6 @@ def _build_quantization_config( quantization: str | None, quantization_config_json: str | None, ignored_layers: str | None, - gguf_model: str | None, ) -> dict[str, Any] | None: explicit = _load_json_arg(quantization_config_json) if explicit is not None: @@ -292,8 +291,6 @@ def _build_quantization_config( ignored = _split_csv(ignored_layers) if ignored: config["ignored_layers"] = ignored - if gguf_model is not None: - config["gguf_model"] = gguf_model return config @@ -305,7 +302,6 @@ def _build_variant_config(args: argparse.Namespace, variant: str) -> VariantConf quantization=quantization, quantization_config_json=getattr(args, f"{variant}_quantization_config_json"), ignored_layers=ignored_layers, - gguf_model=getattr(args, f"{variant}_gguf_model"), ) return VariantConfig( label=variant, @@ -554,8 +550,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--reference-ignored-layers", help="Comma-separated reference ignored layer patterns.") parser.add_argument("--candidate-ignored-layers", help="Comma-separated candidate ignored layer patterns.") parser.add_argument("--ignored-layers", help="Alias for --candidate-ignored-layers.") - parser.add_argument("--reference-gguf-model", help="Reference GGUF file or HF reference when using gguf.") - parser.add_argument("--candidate-gguf-model", help="Candidate GGUF file or HF reference when using gguf.") parser.add_argument("--reference-load-format", default="default", help="Reference diffusion load format.") parser.add_argument("--candidate-load-format", default="default", help="Candidate diffusion load format.") parser.add_argument("--prompt", default="a cup of coffee on the table") From 6581e2afa999867b75b337bba85d0cdafc209723 Mon Sep 17 00:00:00 2001 From: Naga Sai Abhinay Devarinti Date: Sun, 12 Jul 2026 04:55:41 +0530 Subject: [PATCH 064/101] [Model] Add Krea 2 text-to-image diffusion model (#4730) Signed-off-by: Abhinay1997 --- .buildkite/test-nightly.yml | 3 + docs/models/supported_models.md | 1 + docs/user_guide/diffusion_features.md | 2 + .../offline_inference/text_to_image/README.md | 32 + recipes/README.md | 1 + recipes/krea/Krea-2.md | 143 +++++ .../offline_inference/test_krea2_expansion.py | 155 +++++ .../online_serving/test_krea2_expansion.py | 152 +++++ tests/model_tests/diffusion/test_alignment.py | 1 + .../diffusion/cache/cache_dit_backend.py | 33 + vllm_omni/diffusion/models/krea2/__init__.py | 15 + .../models/krea2/krea2_transformer.py | 571 ++++++++++++++++++ .../diffusion/models/krea2/pipeline_krea2.py | 551 +++++++++++++++++ vllm_omni/diffusion/registry.py | 6 + 14 files changed, 1666 insertions(+) create mode 100644 recipes/krea/Krea-2.md create mode 100644 tests/e2e/offline_inference/test_krea2_expansion.py create mode 100644 tests/e2e/online_serving/test_krea2_expansion.py create mode 100644 vllm_omni/diffusion/models/krea2/__init__.py create mode 100644 vllm_omni/diffusion/models/krea2/krea2_transformer.py create mode 100644 vllm_omni/diffusion/models/krea2/pipeline_krea2.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index e3a5aa353..9b1206568 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -650,6 +650,8 @@ steps: tests/e2e/online_serving/test_qwen_image_edit_expansion.py tests/e2e/online_serving/test_qwen_image_layered_expansion.py tests/e2e/online_serving/test_bagel_expansion.py + tests/e2e/online_serving/test_krea2_expansion.py + tests/e2e/offline_inference/test_krea2_expansion.py -m "full_model and diffusion and H100 and not distributed_cuda" --run-level "full_model" agents: @@ -693,6 +695,7 @@ steps: pytest -sv tests/e2e/online_serving/test_qwen_image_expansion.py tests/e2e/online_serving/test_qwen_image_edit_expansion.py + tests/e2e/offline_inference/test_krea2_expansion.py -m "full_model and diffusion and H100 and distributed_cuda" --run-level "full_model" agents: diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 95f93f5c7..99b17d529 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -34,6 +34,7 @@ th { | `QwenImageEditPlusPipeline` | Qwen-Image-Edit-2511 | `Qwen/Qwen-Image-Edit-2511` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | | `GlmImagePipeline` | GLM-Image | `zai-org/GLM-Image` | ✅︎ | ✅︎ | | | | `ZImagePipeline` | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | +| `Krea2Pipeline` | Krea 2 (Raw + Turbo) | `krea/Krea-2-Raw`, `krea/Krea-2-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, V2V, T2V with sound, action policy | `nvidia/Cosmos3-Nano`, `nvidia/Cosmos3-Super` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 81a5aee66..37bc99aa0 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -121,6 +121,7 @@ The following tables show which models support each feature: | **GLM-Image** | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Hidream-I1-Full** | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **HunyuanImage3** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| **Krea 2** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | | **LongCat-Image** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | **LongCat-Image-Edit** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | | **MagiHuman** | ❌ | ❌ | ❌ | ❓ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | @@ -144,6 +145,7 @@ The following tables show which models support each feature: > 1. Nextstep_1(T2I) does not support cache acceleration methods such as TeaCache or Cache-DiT. > 2. `Tongyi-MAI/Z-Image-Turbo` and `SII-GAIR/daVinci-MagiHuman-Base-1080p` are distilled models with minimal NFEs; CFG-Parallel is not necessary. > 3. Cosmos3 T2I uses `Cosmos3OmniDiffusersPipeline` with `modalities=["image"]`. Model-level CPU offload is not supported; use layerwise offload. +> 4. Krea 2 currently supports single-GPU inference plus LoRA, Cache-DiT, HSDP, CPU/layerwise offload, and VAE-patch-parallel (decode). TP/SP/CFG-Parallel are not yet wired. The few-step distilled (Turbo) checkpoint uses `is_distilled=true` (fixed timestep shift `mu=1.15`); generate at 2048x2048 by default with `num_inference_steps≈8` and `guidance_scale=0`. The Raw checkpoint uses 1024x1024, `num_inference_steps=28`, and `guidance_scale=4.5`. ### VideoGen diff --git a/examples/offline_inference/text_to_image/README.md b/examples/offline_inference/text_to_image/README.md index 60ca0fb8e..985c4ff7d 100644 --- a/examples/offline_inference/text_to_image/README.md +++ b/examples/offline_inference/text_to_image/README.md @@ -36,6 +36,7 @@ This folder provides several entrypoints for experimenting with text-to-image di | `black-forest-labs/FLUX.2-dev` | 1024 x 1024 | 65.7 | >80 (CPU offload required) | | `HunyuanImage-3.0` | 1024 x 1024 | 80.0 (TP≥3) | 160 | | `HiDream-I1-Full` | 1024 x 1024 | 63.7 | 57.7 | +| `krea/Krea-2-Raw`, `krea/Krea-2-Turbo` | 1024 (Raw) / 2048 (Turbo) | — | ~30 | !!! info *Peak VRAM: based on basic single-card usage, batch size =1, without any acceleration/optimization features. FLUX.2-dev requires `--enable-cpu-offload` on a single 80 GiB GPU. @@ -196,6 +197,37 @@ python examples/offline_inference/text_to_image/text_to_image.py \ --output /output.png ``` +### Krea 2 + +Krea 2 is published as diffusers-format checkpoints: `krea/Krea-2-Turbo` (few-step distilled) and `krea/Krea-2-Raw`. +The pipeline class (`Krea2Pipeline`) is auto-detected from `model_index.json`. The distilled Turbo checkpoint sets +`is_distilled=true` and is best run at 2048x2048 with few steps and guidance disabled: + +```bash +# Few-step distilled (Turbo) checkpoint +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Turbo \ + --prompt "a fox in the snow" \ + --num-inference-steps 8 \ + --guidance-scale 0.0 \ + --height 2048 --width 2048 \ + --output krea2.png +``` + +For the Raw checkpoint, use more steps with CFG enabled: + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Raw \ + --prompt "a fox in the snow" \ + --num-inference-steps 28 \ + --guidance-scale 4.5 \ + --output krea2.png +``` + +`guidance-scale` follows the Krea 2 convention (`velocity = cond + guidance_scale * (cond - uncond)`); CFG is enabled +whenever `guidance-scale > 0`. + ### Batch Requests (Multiple Prompts) You can pass multiple prompts in a single `Omni.generate` call. `Omni` diff --git a/recipes/README.md b/recipes/README.md index 77a78ef7d..d335183ca 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -44,6 +44,7 @@ recipes/ | [`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.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) | | [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU | +| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB | | [`LTX/LTX-2.md`](./LTX/LTX-2.md) | 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) | | [`MammothModa2/MammothModa2-Preview.md`](./MammothModa2/MammothModa2-Preview.md) | Text-to-image with the shared offline image example (AR → DiT) | 1x L40S 48GB / 1x ≥40GB GPU | diff --git a/recipes/krea/Krea-2.md b/recipes/krea/Krea-2.md new file mode 100644 index 000000000..80e5105dd --- /dev/null +++ b/recipes/krea/Krea-2.md @@ -0,0 +1,143 @@ +# Krea 2 for text-to-image (base + LoRA) + +## Summary + +- Vendor: Krea +- Model: `krea/Krea-2-Turbo` (few-step distilled) and `krea/Krea-2-Raw` +- Task: Text-to-image (T2I) +- Mode: Offline engine and online OpenAI-compatible serving +- Maintainer: Community + +## When to use this recipe + +Use this recipe for a known-good starting point to run the Krea 2 single-stream +MMDiT with vLLM-Omni on a **single 80 GB NVIDIA H100** — both the offline +`text_to_image.py` entrypoint and the online `/v1/images/generations` route, +including PEFT LoRA adapters. `Krea2Pipeline` is auto-detected from +`model_index.json`; guidance is checkpoint-aware (distilled Turbo runs no-CFG, +Raw runs CFG). + +## References + +- Related example under `examples/`: + [`examples/offline_inference/text_to_image/README.md`](../../examples/offline_inference/text_to_image/README.md) + (see the "Krea 2" section) +- Feature support: + [`docs/user_guide/diffusion_features.md`](../../docs/user_guide/diffusion_features.md) +- Related PR: [#4730](https://github.com/vllm-project/vllm-omni/pull/4730) + +## Hardware Support + +This recipe documents a **single-GPU** CUDA layout on H100 80 GB. Add more +platforms (ROCm / NPU) or multi-GPU layouts as community validation lands. + +## GPU + +### 1× H100 80GB + +#### Environment + +Versions from a working editable install (activate `vllm-omni/.venv` or your equivalent): + +- OS: Linux +- Python: 3.12 +- Driver / runtime: NVIDIA driver **595.71.05** (CUDA 13.x) +- torch: **2.11.0+cu130** +- vLLM: **0.24.0** +- vLLM-Omni: editable install from this repo (Git **`960ce8f6`**) +- Transformers: **5.12.1** + +Krea 2 weights are ~30 GB and fit comfortably on a single 80 GB H100. + +#### Command + +Offline — few-step distilled (Turbo) checkpoint (2048×2048, no CFG): + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Turbo \ + --prompt "a fox in the snow" \ + --num-inference-steps 8 \ + --guidance-scale 0.0 \ + --height 2048 --width 2048 \ + --output krea2_turbo.png +``` + +Offline — Raw checkpoint (1024×1024, CFG enabled): + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Raw \ + --prompt "a fox in the snow" \ + --num-inference-steps 28 \ + --guidance-scale 4.5 \ + --height 1024 --width 1024 \ + --output krea2_raw.png +``` + +LoRA (offline) — pass a PEFT adapter with `--lora-path` / `--lora-scale`. +`NagaSaiAbhinay/Krea-2-vllm-darkbrush-LoRA` is a vLLM-Omni-compatible repackaging +of `krea/Krea-2-LoRA-darkbrush`; its trigger word is `monochrome ink wash style`: + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Turbo \ + --prompt "a fox in the snow, monochrome ink wash style" \ + --num-inference-steps 8 --guidance-scale 0.0 \ + --lora-path NagaSaiAbhinay/Krea-2-vllm-darkbrush-LoRA --lora-scale 1.0 \ + --output krea2_darkbrush.png +``` + +Online — serve and query the OpenAI-compatible image route: + +```bash +vllm serve krea/Krea-2-Turbo --omni --enforce-eager --port 8091 +``` + +```bash +curl -s http://localhost:8091/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "a fox in the snow, monochrome ink wash style", + "size": "1024x1024", + "num_inference_steps": 8, + "guidance_scale": 0.0, + "seed": 42, + "lora": {"name": "darkbrush", "path": "NagaSaiAbhinay/Krea-2-vllm-darkbrush-LoRA", "scale": 1.0} + }' | jq -r '.data[0].b64_json' | base64 -d > krea2_online.png +``` + +Cache-DiT acceleration (offline) — add `--cache-backend cache_dit`: + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model krea/Krea-2-Turbo --prompt "a fox in the snow" \ + --num-inference-steps 8 --guidance-scale 0.0 \ + --cache-backend cache_dit --output krea2_cachedit.png +``` + +#### Verification + +```bash +# Offline: the script prints "Saved generated image to "; confirm the file exists. +ls -lh krea2_turbo.png +``` + +For the online route, decode the returned `b64_json` (see the `curl` above) and +confirm a valid 1024×1024 PNG is written. + +#### Notes + +- Key flags: distilled Turbo → few steps (~8) with `guidance_scale=0.0`; Raw → + more steps (~28) with `guidance_scale>0` (CFG). `guidance-scale` follows the + Krea 2 convention `velocity = cond + guidance_scale * (cond - uncond)`. +- LoRA: adapters must be PEFT format (`adapter_config.json` + + `adapter_model.safetensors`); the projections are `ReplicatedLinear` + (264 modules). Offline uses `--lora-path` (which sets + `OmniDiffusionSamplingParams.lora_request` / `lora_scale`); online uses the + top-level `lora` object. Style LoRAs like darkbrush need their trigger word in + the prompt. +- Cache-DiT is supported; `has_separate_cfg` is checkpoint-aware (False for Turbo + no-CFG, True for Raw CFG). +- Known limitations: TP / SP / CFG-Parallel are not yet wired for Krea 2. HSDP, + layerwise CPU offload, and VAE-patch-parallel (decode) are supported. diff --git a/tests/e2e/offline_inference/test_krea2_expansion.py b/tests/e2e/offline_inference/test_krea2_expansion.py new file mode 100644 index 000000000..f00b4bb0b --- /dev/null +++ b/tests/e2e/offline_inference/test_krea2_expansion.py @@ -0,0 +1,155 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""L4 functionality tests for the Krea 2 text-to-image diffusion pipeline (``Krea2Pipeline``). + +Runs against the public few-step distilled checkpoint ``krea/Krea-2-Turbo`` by default (override with the +``KREA2_MODEL`` environment variable, e.g. ``krea/Krea-2-Raw`` for the Raw checkpoint or a local diffusers +directory). They cover a basic functional smoke plus the layerwise-CPU-offload path that the pipeline declares +via ``SupportsComponentDiscovery`` / ``_layerwise_offload_blocks_attrs``. +""" + +import os + +import numpy as np +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunnerHandler +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 = os.environ.get("KREA2_MODEL", "krea/Krea-2-Turbo") +# vLLM-Omni-compatible PEFT repackaging of krea/Krea-2-LoRA-darkbrush (264 modules, r=alpha=32). +LORA = os.environ.get("KREA2_LORA", "NagaSaiAbhinay/Krea-2-vllm-darkbrush-LoRA") +PROMPT = "a fox in the snow, photorealistic" +# darkbrush is a trigger-word LoRA: the trained monochrome ink-wash style only manifests when +# its trigger phrase is present, so the LoRA test prompt appends it for a strong, stable signal. +LORA_TRIGGER = "monochrome ink wash style" +LORA_PROMPT = f"a fox in the snow, {LORA_TRIGGER}" + +pytestmark = [ + pytest.mark.diffusion, + pytest.mark.full_model, +] + + +def _sampling() -> OmniDiffusionSamplingParams: + # Small resolution + few steps to keep the L4 case light. guidance_scale resolution is checkpoint-aware inside + # the pipeline (distilled -> no-CFG, Raw -> CFG), so this stays agnostic to which checkpoint KREA2_MODEL points at. + return OmniDiffusionSamplingParams( + height=512, + width=512, + num_inference_steps=8, + guidance_scale=0.0, + seed=42, + ) + + +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True) +def test_krea2_text_to_image_001(omni_runner_handler: OmniRunnerHandler) -> None: + """Basic functional smoke: a single prompt produces a decoded image.""" + omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()}) + + +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.parametrize( + "omni_runner", + [(MODEL, None, {"enable_layerwise_offload": True})], + indirect=True, +) +def test_krea2_layerwise_offload(omni_runner_handler: OmniRunnerHandler) -> None: + """Exercise layerwise CPU offload on the DiT (SupportsComponentDiscovery + _layerwise_offload_blocks_attrs).""" + omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()}) + + +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.cache +@pytest.mark.parametrize("omni_runner", [(MODEL, None, {"cache_backend": "cache_dit"})], indirect=True) +def test_krea2_cache_dit(omni_runner_handler: OmniRunnerHandler) -> None: + """Exercise Cache-DiT on Krea 2 via the custom Krea2Pipeline enabler (``enable_cache_for_krea2``). + + Validates the docs' Cache-DiT support claim for Krea 2. ``has_separate_cfg`` is checkpoint-aware + (False for the distilled Turbo no-CFG path, True for the Raw CFG path); the default Turbo checkpoint + exercises the no-CFG branch. + """ + omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()}) + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize( + "omni_runner", + # Standalone HSDP: use_hsdp=True with an explicit hsdp_shard_size shards the DiT weights + # across 2 ranks (replicate_size defaults to 1 -> world_size = 1 * 2 = 2). HSDP must keep + # tensor_parallel_size/data_parallel_size at 1 (see vllm_omni/diffusion/data.py __post_init__). + # Also validated locally at 8 GPUs (hsdp_shard_size=8); CI shard provisions 2. + [(MODEL, None, {"use_hsdp": True, "hsdp_shard_size": 2})], + indirect=True, +) +def test_krea2_hsdp(omni_runner_handler: OmniRunnerHandler) -> None: + """Exercise Hybrid Sharded Data Parallel (HSDP) weight sharding on the DiT across 2 GPUs. + + Validates the docs' HSDP-support claim for Krea 2. Scheduled on the multi-GPU (distributed_cuda) + shard by ``num_cards=2``; skipped automatically on boxes with fewer than 2 CUDA devices. + """ + omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()}) + + +@hardware_test(res={"cuda": "H100"}, num_cards=2) +@pytest.mark.parametrize( + "omni_runner", + # VAE patch-parallel decode: partition the VAE decode across the TP world. Mirrors the online + # "tp_vae_patch" case (tensor_parallel_size=2 + vae_patch_parallel_size=2) from the wan2.2/z-image + # expansion suites, so vae_patch_parallel_size must equal the world size established by TP. + # Also validated locally at 8 GPUs (tp=8, vae_patch=8); CI shard provisions 2. + [(MODEL, None, {"tensor_parallel_size": 2, "vae_patch_parallel_size": 2})], + indirect=True, +) +def test_krea2_vae_patch_parallel(omni_runner_handler: OmniRunnerHandler) -> None: + """Exercise VAE patch-parallel (decode) across 2 GPUs. + + Validates the docs' VAE-patch-parallel (decode) support claim for Krea 2. Scheduled on the + multi-GPU (distributed_cuda) shard by ``num_cards=2``; skipped automatically on boxes with + fewer than 2 CUDA devices. + """ + omni_runner_handler.send_diffusion_request({"model": MODEL, "prompt": PROMPT, "sampling_params": _sampling()}) + + +def _generate(handler: OmniRunnerHandler, lora_request: LoRARequest | None, lora_scale: float = 1.0) -> np.ndarray: + sp = _sampling() + sp.lora_request = lora_request + sp.lora_scale = lora_scale + resp = handler.send_diffusion_request({"model": MODEL, "prompt": LORA_PROMPT, "sampling_params": sp}) + return np.asarray(resp.images[0].convert("RGB"), dtype=np.int16) + + +@hardware_test(res={"cuda": "H100"}) +@pytest.mark.parametrize("omni_runner", [(MODEL, None)], indirect=True) +def test_krea2_lora(omni_runner_handler: OmniRunnerHandler) -> None: + """Validate diffusion LoRA on Krea 2: visible effect, scale sensitivity, clean deactivation. + + Uses ``LORA`` (a vLLM-Omni-compatible PEFT repackaging of ``krea/Krea-2-LoRA-darkbrush``, + 264 modules matched via the ReplicatedLinear projections) passed per-request through + ``OmniDiffusionSamplingParams.lora_request`` / ``lora_scale`` — the same fields the + diffusion LoRA manager reads to activate/deactivate the adapter. + """ + lora_request = LoRARequest(lora_name="darkbrush", lora_int_id=stable_lora_int_id(LORA), lora_path=LORA) + + baseline = _generate(omni_runner_handler, None) + img_1x = _generate(omni_runner_handler, lora_request, lora_scale=1.0) + img_2x = _generate(omni_runner_handler, lora_request, lora_scale=2.0) + restored = _generate(omni_runner_handler, None) + + diff_1x = np.abs(baseline - img_1x).mean() + diff_2x = np.abs(baseline - img_2x).mean() + diff_restored = np.abs(baseline - restored).mean() + + # (a) Adapter has a 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) Scale changes the output (stronger adapter -> larger perturbation). + assert not np.isclose(diff_1x, diff_2x, atol=1.0), f"LoRA scale had no effect: 1x={diff_1x:.2f}, 2x={diff_2x:.2f}" + # (c) Passing lora_request=None cleanly deactivates: same seed -> back to baseline (modulo fp drift). + assert diff_restored < 5.0, f"LoRA did not deactivate cleanly: diff_restored={diff_restored:.2f}" diff --git a/tests/e2e/online_serving/test_krea2_expansion.py b/tests/e2e/online_serving/test_krea2_expansion.py new file mode 100644 index 000000000..10aa520fb --- /dev/null +++ b/tests/e2e/online_serving/test_krea2_expansion.py @@ -0,0 +1,152 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""L4 online-serving tests for the Krea 2 text-to-image diffusion pipeline. + +Exercises the OpenAI-compatible ``/v1/images/generations`` route (served via +``vllm serve --omni --enforce-eager``) rather than the offline engine path +covered by ``tests/e2e/offline_inference/test_krea2_expansion.py``. + +Runs against the public few-step distilled checkpoint ``krea/Krea-2-Turbo`` by default +(override with the ``KREA2_MODEL`` environment variable, e.g. ``krea/Krea-2-Raw`` for the +Raw checkpoint or a local diffusers directory). Krea 2 shares the Qwen-Image VAE and a +similar single-stream MMDiT, so this test mirrors the structure/fixtures/markers of +``test_qwen_image_expansion.py``. + +Coverage mirrors the offline suite: a basic functional smoke plus the layerwise-CPU-offload +path that the pipeline declares via ``SupportsComponentDiscovery`` / +``_layerwise_offload_blocks_attrs``. Sizing is kept light (512x512, few steps) so the case +fits single-GPU H100 CI rather than the full 2048x2048 distilled resolution. +""" + +import base64 +import os +from io import BytesIO + +import numpy as np +import pytest +import requests +from PIL import Image + +from tests.helpers.mark import hardware_marks +from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler, dummy_messages_from_mix_data + +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] + +MODEL = os.environ.get("KREA2_MODEL", "krea/Krea-2-Turbo") +# vLLM-Omni-compatible PEFT repackaging of krea/Krea-2-LoRA-darkbrush (264 modules, r=alpha=32). +LORA = os.environ.get("KREA2_LORA", "NagaSaiAbhinay/Krea-2-vllm-darkbrush-LoRA") +PROMPT = "a fox in the snow, photorealistic" +# darkbrush is a trigger-word LoRA; include its trigger phrase so the LoRA case has a strong signal. +LORA_PROMPT = "a fox in the snow, monochrome ink wash style" + +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) + + +def _get_diffusion_feature_cases(model: str): + """Return single-card L4 online-serving cases for Krea 2. + + Mirrors the offline suite: a basic functional smoke plus layerwise CPU offload. + """ + return [ + # Basic functional smoke (single-card). + pytest.param( + OmniServerParams( + model=model, + server_args=["--enforce-eager"], + ), + id="single_card_basic", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + # Layerwise CPU offload (single-card): SupportsComponentDiscovery + + # _layerwise_offload_blocks_attrs. + pytest.param( + OmniServerParams( + model=model, + server_args=["--enforce-eager", "--enable-layerwise-offload"], + ), + id="single_card_layerwise_offload", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + ] + + +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases(MODEL), + indirect=True, +) +def test_krea2(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Serve Krea 2 and validate the OpenAI-compatible image-generation route. + + guidance_scale resolution is checkpoint-aware inside the pipeline (distilled -> no-CFG, + Raw -> CFG), so guidance_scale=0.0 stays agnostic to which checkpoint KREA2_MODEL points at. + Validation is delegated to assert_diffusion_response, which checks output dimensions and + basic correctness. + """ + messages = dummy_messages_from_mix_data(content_text=PROMPT) + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 8, + "guidance_scale": 0.0, + "seed": 42, + }, + } + openai_client.send_diffusion_request(request_config) + + +def _basic_lora_payload(lora: dict | None = None) -> dict: + payload = { + "prompt": LORA_PROMPT, + "n": 1, + "size": "512x512", + "num_inference_steps": 8, + "guidance_scale": 0.0, + "seed": 42, + } + if lora is not None: + payload["lora"] = lora + return payload + + +def _post_image(server: OmniServer, payload: dict) -> np.ndarray: + url = f"http://{server.host}:{server.port}/v1/images/generations" + resp = requests.post(url, json=payload, headers={"Authorization": "Bearer EMPTY"}, timeout=900) + resp.raise_for_status() + b64 = resp.json()["data"][0]["b64_json"] + img = Image.open(BytesIO(base64.b64decode(b64))) + img.load() + return np.asarray(img.convert("RGB"), dtype=np.int16) + + +@pytest.mark.parametrize( + "omni_server", + [ + pytest.param( + OmniServerParams(model=MODEL, server_args=["--enforce-eager"]), + id="single_card_lora", + marks=SINGLE_CARD_FEATURE_MARKS, + ) + ], + indirect=True, +) +def test_krea2_lora(omni_server: OmniServer): + """Serve Krea 2 and validate per-request LoRA over ``/v1/images/generations``. + + Passes ``LORA`` via the top-level ``lora`` object (``{name, path, scale}``) the diffusion + image route parses into a ``LoRARequest`` — no server-side LoRA flag needed. Confirms the + adapter has a visible effect and that omitting ``lora`` cleanly restores the base output. + """ + baseline = _post_image(omni_server, _basic_lora_payload()) + with_lora = _post_image(omni_server, _basic_lora_payload({"name": "darkbrush", "path": LORA, "scale": 1.0})) + restored = _post_image(omni_server, _basic_lora_payload()) + + diff_lora = np.abs(baseline - with_lora).mean() + diff_restored = np.abs(baseline - restored).mean() + + assert diff_lora > 0.5, f"LoRA had no visible effect over the serving path: diff={diff_lora}" + assert diff_restored < 5.0, f"LoRA did not deactivate cleanly: diff_restored={diff_restored:.2f}" diff --git a/tests/model_tests/diffusion/test_alignment.py b/tests/model_tests/diffusion/test_alignment.py index beb553994..ae01334f6 100644 --- a/tests/model_tests/diffusion/test_alignment.py +++ b/tests/model_tests/diffusion/test_alignment.py @@ -56,6 +56,7 @@ EXCLUDED_MODELS = [ "NextStep11Pipeline", "FluxPipeline", "FluxDMD2Pipeline", + "Krea2Pipeline", "QwenImageDMD2Pipeline", "OmniGen2Pipeline", "HeliosPipeline", diff --git a/vllm_omni/diffusion/cache/cache_dit_backend.py b/vllm_omni/diffusion/cache/cache_dit_backend.py index e3d07bfcd..48e64de0a 100644 --- a/vllm_omni/diffusion/cache/cache_dit_backend.py +++ b/vllm_omni/diffusion/cache/cache_dit_backend.py @@ -975,6 +975,38 @@ def enable_cache_for_cosmos3(pipeline: Any, cache_config: Any) -> RefreshCacheCo return enable_cache_for_dit(pipeline, cache_config, block_adapter) +def enable_cache_for_krea2(pipeline: Any, cache_config: Any) -> RefreshCacheContextFunc: + """Enable cache-dit for Krea 2. + + Krea 2 is a single-stream MMDiT: each ``Krea2TransformerBlock`` takes and returns only + ``hidden_states`` (text is fused into the token stream), so the blocks follow + ``ForwardPattern.Pattern_3``. + + ``has_separate_cfg`` is checkpoint-dependent, which is why this needs a custom enabler + rather than a static ``_cache_dit_adapter_config``: the distilled Turbo checkpoint runs + no-CFG (a single transformer forward per denoise step), while the Raw checkpoint runs CFG + as two separate forwards. cache-dit tells cond/uncond apart purely by transformer-forward + parity, so the flag must match the actual per-step forward count — ``True`` only for the + CFG (Raw) path. The pipeline exposes this via ``is_distilled`` (read from ``model_index.json``). + + Args: + pipeline: The Krea2Pipeline instance. + cache_config: DiffusionCacheConfig instance with cache configuration. + + Returns: + A refresh function that can be called to update cache context with new num_inference_steps. + """ + transformer = default_get_pipeline_transformer(pipeline) + block_adapter = BlockAdapter( + transformer=transformer, + blocks=[transformer.transformer_blocks], + forward_pattern=[ForwardPattern.Pattern_3], + has_separate_cfg=not pipeline.is_distilled, + check_forward_pattern=False, + ) + return enable_cache_for_dit(pipeline, cache_config, block_adapter) + + # Register custom cache-dit enablers after function definitions CUSTOM_DIT_ENABLERS.update( { @@ -984,6 +1016,7 @@ CUSTOM_DIT_ENABLERS.update( "Wan22VACEPipeline": enable_cache_for_wan22, "Wan22S2VPipeline": enable_cache_for_wan22_s2v, "Cosmos3OmniDiffusersPipeline": enable_cache_for_cosmos3, + "Krea2Pipeline": enable_cache_for_krea2, } ) diff --git a/vllm_omni/diffusion/models/krea2/__init__.py b/vllm_omni/diffusion/models/krea2/__init__.py new file mode 100644 index 000000000..e802b0984 --- /dev/null +++ b/vllm_omni/diffusion/models/krea2/__init__.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Krea 2 diffusion model components.""" + +from vllm_omni.diffusion.models.krea2.krea2_transformer import Krea2Transformer2DModel +from vllm_omni.diffusion.models.krea2.pipeline_krea2 import ( + Krea2Pipeline, + get_krea2_post_process_func, +) + +__all__ = [ + "Krea2Pipeline", + "Krea2Transformer2DModel", + "get_krea2_post_process_func", +] diff --git a/vllm_omni/diffusion/models/krea2/krea2_transformer.py b/vllm_omni/diffusion/models/krea2/krea2_transformer.py new file mode 100644 index 000000000..e8c0247b5 --- /dev/null +++ b/vllm_omni/diffusion/models/krea2/krea2_transformer.py @@ -0,0 +1,571 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +# Copyright 2026 Krea AI and The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import math +from collections.abc import Iterable +from typing import TYPE_CHECKING + +import torch +import torch.nn as nn +import torch.nn.functional as F +from diffusers.models.embeddings import apply_rotary_emb, get_1d_rotary_pos_embed +from vllm.logger import init_logger +from vllm.model_executor.layers.linear import ReplicatedLinear +from vllm.model_executor.model_loader.weight_utils import default_weight_loader + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.layer import Attention + +if TYPE_CHECKING: + from vllm.model_executor.layers.quantization.base_config import QuantizationConfig + + from vllm_omni.diffusion.data import OmniDiffusionConfig + +logger = init_logger(__name__) + +# Devices that do not support float64; RoPE frequencies fall back to float32 there. +_FP64_UNSUPPORTED_DEVICES = frozenset({"mps", "npu", "neuron"}) + + +def _rope_freqs_dtype(device: torch.device) -> torch.dtype: + return torch.float32 if device.type in _FP64_UNSUPPORTED_DEVICES else torch.float64 + + +def _join_prefix(prefix: str, name: str) -> str: + return f"{prefix}.{name}" if prefix else name + + +def _linear( + in_features: int, + out_features: int, + bias: bool, + quant_config: "QuantizationConfig | None", + prefix: str, +) -> ReplicatedLinear: + # ReplicatedLinear (not nn.Linear) so the diffusion LoRA manager can wrap these projections. + return ReplicatedLinear( + in_features, + out_features, + bias=bias, + quant_config=quant_config, + prefix=prefix, + return_bias=False, + ) + + +class Krea2RMSNorm(nn.Module): + """RMSNorm with a zero-centered scale: the effective multiplier is ``1 + weight``, matching the Krea 2 checkpoint + format. Activations are upcast so the normalization runs in float32.""" + + def __init__(self, dim: int, eps: float = 1e-5) -> None: + super().__init__() + self.dim = dim + self.eps = eps + self.weight = nn.Parameter(torch.zeros(dim)) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + dtype = hidden_states.dtype + hidden_states = F.rms_norm(hidden_states.float(), (self.dim,), weight=self.weight + 1.0, eps=self.eps) + return hidden_states.to(dtype) + + +class Krea2Attention(nn.Module): + """Self-attention with grouped-query projections, q/k RMSNorm, rotary embeddings and a sigmoid output gate. + + Q/K/V layout is ``[B, seq, heads, head_dim]``; ``attention_mask`` is a 2D boolean key-padding mask + ``(batch, key_seq_len)`` that the backend broadcasts. + """ + + def __init__( + self, + hidden_size: int, + num_heads: int, + num_kv_heads: int | None = None, + eps: float = 1e-5, + role: str = "self", + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + if hidden_size % num_heads != 0: + raise ValueError(f"hidden_size={hidden_size} must be divisible by num_heads={num_heads}") + self.hidden_size = hidden_size + self.num_heads = num_heads + self.num_kv_heads = num_kv_heads if num_kv_heads is not None else num_heads + self.head_dim = hidden_size // num_heads + + q_dim = self.head_dim * self.num_heads + kv_dim = self.head_dim * self.num_kv_heads + self.to_q = _linear(hidden_size, q_dim, False, quant_config, _join_prefix(prefix, "to_q")) + self.to_k = _linear(hidden_size, kv_dim, False, quant_config, _join_prefix(prefix, "to_k")) + self.to_v = _linear(hidden_size, kv_dim, False, quant_config, _join_prefix(prefix, "to_v")) + self.to_gate = _linear(hidden_size, hidden_size, False, quant_config, _join_prefix(prefix, "to_gate")) + self.norm_q = Krea2RMSNorm(self.head_dim, eps=eps) + self.norm_k = Krea2RMSNorm(self.head_dim, eps=eps) + self.to_out = nn.ModuleList( + [_linear(hidden_size, hidden_size, False, quant_config, _join_prefix(prefix, "to_out.0")), nn.Dropout(0.0)] + ) + + self.attn = Attention( + num_heads=self.num_heads, + head_size=self.head_dim, + softmax_scale=1.0 / (self.head_dim**0.5), + causal=False, + num_kv_heads=self.num_kv_heads, + role=role, + ) + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor | None = None, + image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, + ) -> torch.Tensor: + query = self.to_q(hidden_states).unflatten(-1, (self.num_heads, self.head_dim)) + key = self.to_k(hidden_states).unflatten(-1, (self.num_kv_heads, self.head_dim)) + value = self.to_v(hidden_states).unflatten(-1, (self.num_kv_heads, self.head_dim)) + gate = self.to_gate(hidden_states) + + query = self.norm_q(query) + key = self.norm_k(key) + + if image_rotary_emb is not None: + query = apply_rotary_emb(query, image_rotary_emb, sequence_dim=1) + key = apply_rotary_emb(key, image_rotary_emb, sequence_dim=1) + query = query.to(value.dtype) + key = key.to(value.dtype) + + attn_metadata = AttentionMetadata(attn_mask=attention_mask) if attention_mask is not None else None + hidden_states = self.attn(query, key, value, attn_metadata) + hidden_states = hidden_states.flatten(2, 3) + hidden_states = hidden_states * torch.sigmoid(gate) + return self.to_out[0](hidden_states) + + +class Krea2SwiGLU(nn.Module): + """SwiGLU feed-forward network.""" + + def __init__( + self, dim: int, hidden_dim: int, quant_config: "QuantizationConfig | None" = None, prefix: str = "" + ) -> None: + super().__init__() + self.gate = _linear(dim, hidden_dim, False, quant_config, _join_prefix(prefix, "gate")) + self.up = _linear(dim, hidden_dim, False, quant_config, _join_prefix(prefix, "up")) + self.down = _linear(hidden_dim, dim, False, quant_config, _join_prefix(prefix, "down")) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + return self.down(F.silu(self.gate(hidden_states)) * self.up(hidden_states)) + + +class Krea2TextFusionBlock(nn.Module): + """Pre-norm transformer block (no rotary embeddings, no time modulation) used by the text fusion stage.""" + + def __init__( + self, + dim: int, + num_heads: int, + num_kv_heads: int, + intermediate_size: int, + eps: float, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + self.norm1 = Krea2RMSNorm(dim, eps=eps) + self.norm2 = Krea2RMSNorm(dim, eps=eps) + self.attn = Krea2Attention( + dim, + num_heads, + num_kv_heads, + eps=eps, + role="self", + quant_config=quant_config, + prefix=_join_prefix(prefix, "attn"), + ) + self.ff = Krea2SwiGLU(dim, intermediate_size, quant_config=quant_config, prefix=_join_prefix(prefix, "ff")) + + def forward(self, hidden_states: torch.Tensor, attention_mask: torch.Tensor | None = None) -> torch.Tensor: + hidden_states = hidden_states + self.attn(self.norm1(hidden_states), attention_mask=attention_mask) + hidden_states = hidden_states + self.ff(self.norm2(hidden_states)) + return hidden_states + + +class Krea2TextFusion(nn.Module): + """Fuses the stack of tapped text-encoder hidden states into a single sequence of text features. + + Two ``layerwise_blocks`` attend across the ``num_text_layers`` axis independently for every token, a linear + ``projector`` collapses that axis, and two ``refiner_blocks`` attend across the token sequence. + """ + + def __init__( + self, + num_text_layers: int, + dim: int, + num_heads: int, + num_kv_heads: int, + intermediate_size: int, + num_layerwise_blocks: int, + num_refiner_blocks: int, + eps: float, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + self.layerwise_blocks = nn.ModuleList( + [ + Krea2TextFusionBlock( + dim, + num_heads, + num_kv_heads, + intermediate_size, + eps, + quant_config=quant_config, + prefix=_join_prefix(prefix, f"layerwise_blocks.{i}"), + ) + for i in range(num_layerwise_blocks) + ] + ) + self.projector = _linear(num_text_layers, 1, False, quant_config, _join_prefix(prefix, "projector")) + self.refiner_blocks = nn.ModuleList( + [ + Krea2TextFusionBlock( + dim, + num_heads, + num_kv_heads, + intermediate_size, + eps, + quant_config=quant_config, + prefix=_join_prefix(prefix, f"refiner_blocks.{i}"), + ) + for i in range(num_refiner_blocks) + ] + ) + + def forward(self, encoder_hidden_states: torch.Tensor, attention_mask: torch.Tensor | None = None) -> torch.Tensor: + batch_size, seq_len, num_text_layers, dim = encoder_hidden_states.shape + + hidden_states = encoder_hidden_states.reshape(batch_size * seq_len, num_text_layers, dim) + for block in self.layerwise_blocks: + hidden_states = block(hidden_states.contiguous()) + + hidden_states = hidden_states.reshape(batch_size, seq_len, num_text_layers, dim).permute(0, 1, 3, 2) + hidden_states = self.projector(hidden_states).squeeze(-1) + + for block in self.refiner_blocks: + hidden_states = block(hidden_states, attention_mask=attention_mask) + + return hidden_states + + +class Krea2TransformerBlock(nn.Module): + def __init__( + self, + hidden_size: int, + intermediate_size: int, + num_heads: int, + num_kv_heads: int, + norm_eps: float, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + self.scale_shift_table = nn.Parameter(torch.zeros(6, hidden_size)) + self.norm1 = Krea2RMSNorm(hidden_size, eps=norm_eps) + self.norm2 = Krea2RMSNorm(hidden_size, eps=norm_eps) + self.attn = Krea2Attention( + hidden_size, + num_heads, + num_kv_heads, + eps=norm_eps, + role="self", + quant_config=quant_config, + prefix=_join_prefix(prefix, "attn"), + ) + self.ff = Krea2SwiGLU( + hidden_size, intermediate_size, quant_config=quant_config, prefix=_join_prefix(prefix, "ff") + ) + + def forward( + self, + hidden_states: torch.Tensor, + temb: torch.Tensor, + image_rotary_emb: tuple[torch.Tensor, torch.Tensor], + attention_mask: torch.Tensor | None = None, + ) -> torch.Tensor: + # temb: (B, 1, 6 * hidden_size), shared across all blocks; each block only learns an additive table. + modulation = temb.unflatten(-1, (6, -1)) + self.scale_shift_table + prescale, preshift, pregate, postscale, postshift, postgate = modulation.unbind(-2) + + attn_out = self.attn( + (1.0 + prescale) * self.norm1(hidden_states) + preshift, + attention_mask=attention_mask, + image_rotary_emb=image_rotary_emb, + ) + hidden_states = hidden_states + pregate * attn_out + ff_out = self.ff((1.0 + postscale) * self.norm2(hidden_states) + postshift) + hidden_states = hidden_states + postgate * ff_out + return hidden_states + + +class Krea2TimestepEmbedding(nn.Module): + """Sinusoidal flow-time embedding (cos-first, input scaled by 1000) followed by a two-layer MLP. + + Keeps the sequence dimension at size 1 so the per-block modulations broadcast over tokens. + """ + + def __init__( + self, embed_dim: int, hidden_size: int, quant_config: "QuantizationConfig | None" = None, prefix: str = "" + ) -> None: + super().__init__() + self.embed_dim = embed_dim + self.linear_1 = _linear(embed_dim, hidden_size, True, quant_config, _join_prefix(prefix, "linear_1")) + self.linear_2 = _linear(hidden_size, hidden_size, True, quant_config, _join_prefix(prefix, "linear_2")) + + def forward(self, timestep: torch.Tensor, dtype: torch.dtype) -> torch.Tensor: + half = self.embed_dim // 2 + freqs = torch.exp(-math.log(1e4) * torch.arange(half, dtype=torch.float32, device=timestep.device) / half) + args = (timestep.float() * 1e3)[:, None, None] * freqs + emb = torch.cat([torch.cos(args), torch.sin(args)], dim=-1).to(dtype) + return self.linear_2(F.gelu(self.linear_1(emb), approximate="tanh")) + + +class Krea2TextProjection(nn.Module): + """Projects the fused text features into the transformer width.""" + + def __init__( + self, + text_dim: int, + hidden_size: int, + eps: float, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + self.norm = Krea2RMSNorm(text_dim, eps=eps) + self.linear_1 = _linear(text_dim, hidden_size, True, quant_config, _join_prefix(prefix, "linear_1")) + self.linear_2 = _linear(hidden_size, hidden_size, True, quant_config, _join_prefix(prefix, "linear_2")) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + hidden_states = self.linear_1(self.norm(hidden_states)) + return self.linear_2(F.gelu(hidden_states, approximate="tanh")) + + +class Krea2FinalLayer(nn.Module): + """Final adaptive RMSNorm and output projection.""" + + def __init__( + self, + hidden_size: int, + out_channels: int, + eps: float, + quant_config: "QuantizationConfig | None" = None, + prefix: str = "", + ) -> None: + super().__init__() + self.scale_shift_table = nn.Parameter(torch.zeros(2, hidden_size)) + self.norm = Krea2RMSNorm(hidden_size, eps=eps) + self.linear = _linear(hidden_size, out_channels, True, quant_config, _join_prefix(prefix, "linear")) + + def forward(self, hidden_states: torch.Tensor, temb: torch.Tensor) -> torch.Tensor: + modulation = temb + self.scale_shift_table + scale, shift = modulation.chunk(2, dim=1) + hidden_states = (1.0 + scale) * self.norm(hidden_states) + shift + return self.linear(hidden_states) + + +class Krea2RotaryPosEmbed(nn.Module): + """Multi-axis (t, h, w) rotary position embedding, following the Flux/Krea 2 convention.""" + + def __init__(self, theta: int, axes_dim: list[int]): + super().__init__() + self.theta = theta + self.axes_dim = axes_dim + + def forward(self, ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + n_axes = ids.shape[-1] + cos_out = [] + sin_out = [] + pos = ids.float() + freqs_dtype = _rope_freqs_dtype(ids.device) + for i in range(n_axes): + cos, sin = get_1d_rotary_pos_embed( + self.axes_dim[i], + pos[:, i], + theta=self.theta, + repeat_interleave_real=True, + use_real=True, + freqs_dtype=freqs_dtype, + ) + cos_out.append(cos) + sin_out.append(sin) + freqs_cos = torch.cat(cos_out, dim=-1).to(ids.device) + freqs_sin = torch.cat(sin_out, dim=-1).to(ids.device) + return freqs_cos, freqs_sin + + +class Krea2Transformer2DModel(nn.Module): + r"""The single-stream MMDiT flow-matching backbone used by the Krea 2 pipeline (vLLM-Omni port). + + Text conditioning enters as a stack of hidden states tapped from several layers of a multimodal text encoder. A + small text-fusion transformer collapses the layer axis and refines the token sequence; the result is concatenated + with the patchified image latents into a single ``[text, image]`` sequence processed by the transformer blocks. + The timestep conditions every block through one shared modulation vector plus per-block learned tables. + """ + + _repeated_blocks = ["Krea2TransformerBlock"] + _layerwise_offload_blocks_attrs = ["transformer_blocks"] + + @staticmethod + def _is_transformer_block(name: str, module) -> bool: + return "transformer_blocks" in name and name.split(".")[-1].isdigit() + + _hsdp_shard_conditions = [_is_transformer_block] + + def __init__( + self, + in_channels: int = 64, + num_layers: int = 28, + attention_head_dim: int = 128, + num_attention_heads: int = 48, + num_key_value_heads: int = 12, + intermediate_size: int = 16384, + timestep_embed_dim: int = 256, + text_hidden_dim: int = 2560, + num_text_layers: int = 12, + text_num_attention_heads: int = 20, + text_num_key_value_heads: int = 20, + text_intermediate_size: int = 6912, + num_layerwise_text_blocks: int = 2, + num_refiner_text_blocks: int = 2, + axes_dims_rope: tuple[int, int, int] = (32, 48, 48), + rope_theta: float = 1000.0, + norm_eps: float = 1e-5, + od_config: "OmniDiffusionConfig | None" = None, + quant_config: "QuantizationConfig | None" = None, + ) -> None: + super().__init__() + # The pipeline reads ``self.dtype`` to cast inputs before the transformer forward. + self.dtype = od_config.dtype if od_config is not None else torch.get_default_dtype() + self.od_config = od_config + + hidden_size = attention_head_dim * num_attention_heads + if sum(axes_dims_rope) != attention_head_dim: + raise ValueError( + f"sum(axes_dims_rope)={sum(axes_dims_rope)} must equal attention_head_dim={attention_head_dim}" + ) + + self.in_channels = in_channels + self.out_channels = in_channels + self.hidden_size = hidden_size + self.num_layers = num_layers + + self.img_in = _linear(in_channels, hidden_size, True, quant_config, "img_in") + self.time_embed = Krea2TimestepEmbedding(timestep_embed_dim, hidden_size, quant_config, "time_embed") + self.time_mod_proj = _linear(hidden_size, 6 * hidden_size, True, quant_config, "time_mod_proj") + self.text_fusion = Krea2TextFusion( + num_text_layers=num_text_layers, + dim=text_hidden_dim, + num_heads=text_num_attention_heads, + num_kv_heads=text_num_key_value_heads, + intermediate_size=text_intermediate_size, + num_layerwise_blocks=num_layerwise_text_blocks, + num_refiner_blocks=num_refiner_text_blocks, + eps=norm_eps, + quant_config=quant_config, + prefix="text_fusion", + ) + self.txt_in = Krea2TextProjection(text_hidden_dim, hidden_size, norm_eps, quant_config, "txt_in") + self.rotary_emb = Krea2RotaryPosEmbed(theta=rope_theta, axes_dim=list(axes_dims_rope)) + + self.transformer_blocks = nn.ModuleList( + [ + Krea2TransformerBlock( + hidden_size=hidden_size, + intermediate_size=intermediate_size, + num_heads=num_attention_heads, + num_kv_heads=num_key_value_heads, + norm_eps=norm_eps, + quant_config=quant_config, + prefix=f"transformer_blocks.{i}", + ) + for i in range(num_layers) + ] + ) + + self.final_layer = Krea2FinalLayer(hidden_size, in_channels, norm_eps, quant_config, "final_layer") + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor, + timestep: torch.Tensor, + position_ids: torch.Tensor, + encoder_attention_mask: torch.Tensor | None = None, + ) -> torch.Tensor: + r"""Predict the flow-matching velocity for the image tokens. + + Args: + hidden_states: Packed (patchified) noisy image latents ``(batch, image_seq_len, in_channels)``. + encoder_hidden_states: Tapped text-encoder hidden states + ``(batch, text_seq_len, num_text_layers, text_hidden_dim)``. + timestep: Flow-matching time in ``[0, 1]`` of shape ``(batch,)``. + position_ids: ``(t, h, w)`` rotary coordinates ``(text_seq_len + image_seq_len, 3)``. + encoder_attention_mask: Boolean mask marking valid text tokens ``(batch, text_seq_len)`` or ``None``. + """ + if position_ids.ndim != 2 or position_ids.shape[-1] != 3: + raise ValueError(f"`position_ids` must have shape (sequence_length, 3), got {tuple(position_ids.shape)}.") + + batch_size, image_seq_len, _ = hidden_states.shape + + temb = self.time_embed(timestep, dtype=hidden_states.dtype) + temb_mod = self.time_mod_proj(F.gelu(temb, approximate="tanh")) + + # 2D key-padding masks: padded text tokens are excluded as keys; their lanes are dropped at the output slice. + text_attention_mask = None + attention_mask = None + if encoder_attention_mask is not None: + text_attention_mask = encoder_attention_mask.bool() + image_mask = text_attention_mask.new_ones((batch_size, image_seq_len)) + attention_mask = torch.cat([text_attention_mask, image_mask], dim=1) + + encoder_hidden_states = self.text_fusion(encoder_hidden_states, attention_mask=text_attention_mask) + encoder_hidden_states = self.txt_in(encoder_hidden_states) + + text_seq_len = encoder_hidden_states.shape[1] + hidden_states = self.img_in(hidden_states) + hidden_states = torch.cat([encoder_hidden_states, hidden_states], dim=1) + + image_rotary_emb = self.rotary_emb(position_ids) + + for block in self.transformer_blocks: + hidden_states = block(hidden_states, temb_mod, image_rotary_emb, attention_mask) + + hidden_states = hidden_states[:, text_seq_len:] + return self.final_layer(hidden_states, temb) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + # Module/parameter names mirror the diffusers ``Krea2Transformer2DModel`` exactly, so weights map 1:1. + params_dict = dict(self.named_parameters()) + loaded_params: set[str] = set() + for name, loaded_weight in weights: + if name not in params_dict: + continue + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded_params.add(name) + return loaded_params diff --git a/vllm_omni/diffusion/models/krea2/pipeline_krea2.py b/vllm_omni/diffusion/models/krea2/pipeline_krea2.py new file mode 100644 index 000000000..4aa0617c7 --- /dev/null +++ b/vllm_omni/diffusion/models/krea2/pipeline_krea2.py @@ -0,0 +1,551 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +# Copyright 2026 Krea AI and The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import inspect +import logging +import os +from collections.abc import Iterable +from typing import ClassVar + +import numpy as np +import torch +from diffusers.image_processor import VaeImageProcessor +from diffusers.schedulers.scheduling_flow_match_euler_discrete import ( + FlowMatchEulerDiscreteScheduler, +) +from diffusers.utils.torch_utils import randn_tensor +from torch import nn +from transformers import AutoTokenizer, Qwen3VLModel +from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.transformers_utils.config import get_hf_file_to_dict + +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_qwenimage import DistributedAutoencoderKLQwenImage +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.krea2.krea2_transformer import Krea2Transformer2DModel +from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin +from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.utils.tf_utils import get_transformer_config_kwargs + +logger = logging.getLogger(__name__) + +# Text-encoder layer taps used when model_index.json does not specify ``text_encoder_select_layers``. +DEFAULT_TEXT_ENCODER_SELECT_LAYERS = (2, 5, 8, 11, 14, 17, 20, 23, 26, 29, 32, 35) + + +def get_krea2_post_process_func(od_config: OmniDiffusionConfig): + model_name = od_config.model + # Read only the VAE config (``get_hf_file_to_dict`` resolves a local dir via + # ``Path(model)/file`` and a hub repo via a single-file download) instead of + # pulling all weights just to compute ``vae_scale_factor``. + vae_config = get_hf_file_to_dict("vae/config.json", model_name) or {} + vae_scale_factor = 2 ** len(vae_config["temperal_downsample"]) if "temperal_downsample" in vae_config else 8 + + patch_size = 2 + try: + model_index = get_hf_file_to_dict("model_index.json", model_name) or {} + patch_size = int(model_index.get("patch_size", patch_size)) + except Exception: # noqa: BLE001 + pass + + image_processor = VaeImageProcessor(vae_scale_factor=vae_scale_factor * patch_size) + + def post_process_func(images: torch.Tensor): + return image_processor.postprocess(images) + + return post_process_func + + +# Copied from diffusers.pipelines.flux.pipeline_flux.calculate_shift +def calculate_shift( + image_seq_len, + base_seq_len: int = 256, + max_seq_len: int = 4096, + base_shift: float = 0.5, + max_shift: float = 1.15, +): + m = (max_shift - base_shift) / (max_seq_len - base_seq_len) + b = base_shift - m * base_seq_len + mu = image_seq_len * m + b + return mu + + +# Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion.retrieve_timesteps +def retrieve_timesteps( + scheduler, + num_inference_steps: int | None = None, + device: str | torch.device | None = None, + timesteps: list[int] | None = None, + sigmas: list[float] | None = None, + **kwargs, +) -> tuple[torch.Tensor, int]: + if timesteps is not None and sigmas is not None: + raise ValueError("Only one of `timesteps` or `sigmas` can be passed. Please choose one to set custom values") + if timesteps is not None: + accepts_timesteps = "timesteps" in set(inspect.signature(scheduler.set_timesteps).parameters.keys()) + if not accepts_timesteps: + raise ValueError( + f"The current scheduler class {scheduler.__class__}'s `set_timesteps` does not support custom" + f" timestep schedules. Please check whether you are using the correct scheduler." + ) + scheduler.set_timesteps(timesteps=timesteps, device=device, **kwargs) + timesteps = scheduler.timesteps + num_inference_steps = len(timesteps) + elif sigmas is not None: + accept_sigmas = "sigmas" in set(inspect.signature(scheduler.set_timesteps).parameters.keys()) + if not accept_sigmas: + raise ValueError( + f"The current scheduler class {scheduler.__class__}'s `set_timesteps` does not support custom" + f" sigmas schedules. Please check whether you are using the correct scheduler." + ) + scheduler.set_timesteps(sigmas=sigmas, device=device, **kwargs) + timesteps = scheduler.timesteps + num_inference_steps = len(timesteps) + else: + scheduler.set_timesteps(num_inference_steps, device=device, **kwargs) + timesteps = scheduler.timesteps + return timesteps, num_inference_steps + + +class Krea2Pipeline(nn.Module, DiffusionPipelineProfilerMixin, ProgressBarMixin, SupportsComponentDiscovery): + """The Krea 2 text-to-image pipeline (vLLM-Omni port of ``diffusers.Krea2Pipeline``). + + Components: + - ``scheduler``: ``FlowMatchEulerDiscreteScheduler`` (resolution-aware exponential time shift). + - ``vae``: the Qwen-Image VAE (f8, 16 latent channels). + - ``text_encoder``: a Qwen3-VL model; the pipeline consumes a stack of hidden states tapped from several + decoder layers rather than the last hidden state. + - ``tokenizer``: the tokenizer paired with the text encoder. + - ``transformer``: the Krea 2 single-stream MMDiT that predicts the flow-matching velocity. + """ + + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder"] + _vae_modules: ClassVar[list[str]] = ["vae"] + + _PROFILER_TARGETS: ClassVar[list[str]] = ["text_encoder.forward", "diffuse", "vae.decode"] + + def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): + super().__init__() + self.od_config = od_config + self.parallel_config = od_config.parallel_config + # Only the transformer goes through the standard weights loader; text encoder and VAE load eagerly below. + self.weights_sources = [ + DiffusersPipelineLoader.ComponentSource( + model_or_path=od_config.model, + subfolder="transformer", + revision=od_config.revision, + prefix="transformer.", + fall_back_to_pt=True, + ) + ] + + self.device = get_local_device() + model = od_config.model + local_files_only = os.path.isdir(model) + + # Pipeline-level config lives in model_index.json, not the transformer config. + model_index = get_hf_file_to_dict("model_index.json", model) or {} + select_layers = model_index.get("text_encoder_select_layers") or DEFAULT_TEXT_ENCODER_SELECT_LAYERS + self.text_encoder_select_layers = tuple(int(i) for i in select_layers) + self.is_distilled = bool(model_index.get("is_distilled", False)) + self.patch_size = int(model_index.get("patch_size", 2)) + + subfolders = ["scheduler", "text_encoder", "vae", "tokenizer"] + # See ``hub_prefetch.py`` for the transformers v5 subfolder race. + prefetch_subfolders(model, subfolders, local_files_only=local_files_only) + + self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained( + model, subfolder="scheduler", local_files_only=local_files_only + ) + + self.text_encoder = from_pretrained_with_prefetch( + Qwen3VLModel.from_pretrained, + model, + subfolder="text_encoder", + prefetch_list=subfolders, + local_files_only=local_files_only, + torch_dtype=od_config.dtype, + ) + # Drop the unused Qwen3-VL vision tower before moving to GPU so it never consumes GPU memory. + if hasattr(self.text_encoder, "visual"): + del self.text_encoder.visual + else: + logger.warning("Krea2: vision tower not found on text encoder; skipping drop") + self.text_encoder = self.text_encoder.to(self.device) + + self.vae = from_pretrained_with_prefetch( + DistributedAutoencoderKLQwenImage.from_pretrained, + model, + subfolder="vae", + prefetch_list=subfolders, + local_files_only=local_files_only, + torch_dtype=od_config.dtype, + ).to(self.device) + + transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, Krea2Transformer2DModel) + self.transformer = Krea2Transformer2DModel( + od_config=od_config, quant_config=od_config.quantization_config, **transformer_kwargs + ) + + self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only) + + self.vae_scale_factor = 2 ** len(self.vae.temperal_downsample) if getattr(self, "vae", None) else 8 + self.image_processor = VaeImageProcessor(vae_scale_factor=self.vae_scale_factor * self.patch_size) + self.default_sample_size = 1024 + + # Chat template tokenized as a fixed-length block: prompt padded to a fixed length first, assistant suffix + # appended after the padding; the first ``prompt_template_encode_start_idx`` system tokens are later dropped. + self.prompt_template_encode_prefix = ( + "<|im_start|>system\nDescribe the image by detailing the color, shape, size, texture, quantity, text, " + "spatial relationships of the objects and background:<|im_end|>\n<|im_start|>user\n" + ) + self.prompt_template_encode_suffix = "<|im_end|>\n<|im_start|>assistant\n" + self.prompt_template_encode_start_idx = 34 + self.prompt_template_encode_num_suffix_tokens = 5 + + self.setup_diffusion_pipeline_profiler( + enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler + ) + + def get_text_hidden_states( + self, + prompt: str | list[str], + max_sequence_length: int = 512, + device: torch.device | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Tokenize ``prompt`` into the fixed-length Krea 2 layout and tap the selected encoder hidden states. + + Returns ``(hidden_states, attention_mask)`` of shapes ``(batch, text_seq_len, num_text_layers, + text_hidden_dim)`` and ``(batch, text_seq_len)`` (bool). + """ + device = device or self.device + prompt = [prompt] if isinstance(prompt, str) else prompt + prefix_idx = self.prompt_template_encode_start_idx + text = [self.prompt_template_encode_prefix + e for e in prompt] + text_tokens = self.tokenizer( + text, + truncation=True, + padding="max_length", + max_length=max_sequence_length + prefix_idx - self.prompt_template_encode_num_suffix_tokens, + return_tensors="pt", + ).to(device) + suffix_tokens = self.tokenizer([self.prompt_template_encode_suffix] * len(text), return_tensors="pt").to(device) + + input_ids = torch.cat([text_tokens.input_ids, suffix_tokens.input_ids], dim=1) + attention_mask = torch.cat([text_tokens.attention_mask, suffix_tokens.attention_mask], dim=1).bool() + + # Padding sits in the middle (``[prefix | prompt | PAD | suffix]``), so positions must count only real tokens + # (padding consumes no position). Broadcast across the 3 mRoPE axes (T/H/W equal for text). + position_ids = (attention_mask.long().cumsum(dim=-1) - 1).clamp(min=0) + position_ids = position_ids.unsqueeze(0).expand(3, -1, -1) + + outputs = self.text_encoder( + input_ids=input_ids, + attention_mask=attention_mask, + position_ids=position_ids, + output_hidden_states=True, + ) + hidden_states = torch.stack([outputs.hidden_states[i] for i in self.text_encoder_select_layers], dim=2) + + hidden_states = hidden_states[:, prefix_idx:] + attention_mask = attention_mask[:, prefix_idx:] + return hidden_states, attention_mask + + def encode_prompt( + self, + prompt: str | list[str], + device: torch.device | None = None, + num_images_per_prompt: int = 1, + prompt_embeds: torch.Tensor | None = None, + prompt_embeds_mask: torch.Tensor | None = None, + max_sequence_length: int = 512, + ) -> tuple[torch.Tensor, torch.Tensor]: + device = device or self.device + + if prompt_embeds is None: + prompt_embeds, prompt_embeds_mask = self.get_text_hidden_states(prompt, max_sequence_length, device) + + batch_size, seq_len, num_text_layers, dim = prompt_embeds.shape + prompt_embeds = prompt_embeds.repeat(1, num_images_per_prompt, 1, 1) + prompt_embeds = prompt_embeds.view(batch_size * num_images_per_prompt, seq_len, num_text_layers, dim) + prompt_embeds_mask = prompt_embeds_mask.repeat(1, num_images_per_prompt) + prompt_embeds_mask = prompt_embeds_mask.view(batch_size * num_images_per_prompt, seq_len) + + return prompt_embeds, prompt_embeds_mask + + def _pack_latents(self, latents, batch_size, num_channels_latents, height, width): + p = self.patch_size + latents = latents.view(batch_size, num_channels_latents, height // p, p, width // p, p) + latents = latents.permute(0, 2, 4, 1, 3, 5) + latents = latents.reshape(batch_size, (height // p) * (width // p), num_channels_latents * p * p) + return latents + + def _unpack_latents(self, latents, height, width): + batch_size, _, channels = latents.shape + p = self.patch_size + + height = p * (int(height) // (self.vae_scale_factor * p)) + width = p * (int(width) // (self.vae_scale_factor * p)) + + latents = latents.view(batch_size, height // p, width // p, channels // (p * p), p, p) + latents = latents.permute(0, 3, 1, 4, 2, 5) + latents = latents.reshape(batch_size, channels // (p * p), 1, height, width) + return latents + + @staticmethod + def prepare_position_ids(text_seq_len: int, grid_height: int, grid_width: int, device: torch.device): + """Build the ``(text_seq_len + grid_height * grid_width, 3)`` rotary coordinates: text tokens sit at the + origin, image tokens carry their ``(0, h, w)`` latent-grid coordinates.""" + text_ids = torch.zeros(text_seq_len, 3, device=device) + image_ids = torch.zeros(grid_height, grid_width, 3, device=device) + image_ids[..., 1] = torch.arange(grid_height, device=device)[:, None] + image_ids[..., 2] = torch.arange(grid_width, device=device)[None, :] + image_ids = image_ids.reshape(grid_height * grid_width, 3) + return torch.cat([text_ids, image_ids], dim=0) + + def prepare_latents(self, batch_size, num_channels_latents, height, width, dtype, device, generator, latents=None): + if latents is not None: + return latents.to(device=device, dtype=dtype) + + latent_height = height // self.vae_scale_factor + latent_width = width // self.vae_scale_factor + shape = (batch_size, num_channels_latents, latent_height, latent_width) + + if isinstance(generator, list) and len(generator) != batch_size: + raise ValueError( + f"You have passed a list of generators of length {len(generator)}, but requested an effective batch" + f" size of {batch_size}. Make sure the batch size matches the length of the generators." + ) + + latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype) + latents = self._pack_latents(latents, batch_size, num_channels_latents, latent_height, latent_width) + return latents + + def _extract_prompts(self, prompts): + """Extract prompt and negative_prompt from the request's OmniPromptType list.""" + prompt = [p if isinstance(p, str) else (p.get("prompt") or "") for p in prompts] or None + if all(isinstance(p, str) or p.get("negative_prompt") is None for p in prompts): + negative_prompt = None + elif prompts: + negative_prompt = ["" if isinstance(p, str) else (p.get("negative_prompt") or "") for p in prompts] + else: + negative_prompt = None + return prompt, negative_prompt + + @property + def do_classifier_free_guidance(self): + return self._guidance_scale > 0 + + def diffuse( + self, + latents: torch.Tensor, + timesteps: torch.Tensor, + position_ids: torch.Tensor, + prompt_embeds: torch.Tensor, + prompt_embeds_mask: torch.Tensor, + negative_prompt_embeds: torch.Tensor | None, + negative_prompt_embeds_mask: torch.Tensor | None, + guidance_scale: float, + ) -> torch.Tensor: + """Run the flow-matching denoising loop and return the final latents.""" + self.scheduler.set_begin_index(0) + transformer_dtype = self.transformer.dtype + with self.progress_bar(total=len(timesteps)) as progress_bar: + for _, t in enumerate(timesteps): + timestep = (t / self.scheduler.config.num_train_timesteps).expand(latents.shape[0]) + timestep = timestep.to(transformer_dtype) + latent_input = latents.to(transformer_dtype) + + noise_pred = self.transformer( + hidden_states=latent_input, + encoder_hidden_states=prompt_embeds.to(transformer_dtype), + timestep=timestep, + position_ids=position_ids, + encoder_attention_mask=prompt_embeds_mask, + ) + + if self.do_classifier_free_guidance: + neg_noise_pred = self.transformer( + hidden_states=latent_input, + encoder_hidden_states=negative_prompt_embeds.to(transformer_dtype), + timestep=timestep, + position_ids=position_ids, + encoder_attention_mask=negative_prompt_embeds_mask, + ) + noise_pred = noise_pred + guidance_scale * (noise_pred - neg_noise_pred) + + latents = self.scheduler.step(noise_pred, t, latents, return_dict=False)[0] + progress_bar.update() + + return latents + + def forward( + self, + req: OmniDiffusionRequest, + prompt: str | list[str] | None = None, + negative_prompt: str | list[str] | None = None, + height: int = 1024, + width: int = 1024, + num_inference_steps: int = 28, + sigmas: list[float] | None = None, + guidance_scale: float = 4.5, + num_images_per_prompt: int = 1, + generator: torch.Generator | list[torch.Generator] | None = None, + latents: torch.Tensor | None = None, + output_type: str | None = "pil", + max_sequence_length: int = 512, + ) -> DiffusionOutput: + extracted_prompt, extracted_negative = self._extract_prompts(req.prompts) + prompt = extracted_prompt or prompt + if extracted_negative is not None: + negative_prompt = extracted_negative + + sp = req.sampling_params + height = sp.height or height + width = sp.width or width + num_inference_steps = sp.num_inference_steps or num_inference_steps + sigmas = sp.sigmas or sigmas + max_sequence_length = sp.max_sequence_length or max_sequence_length + generator = sp.generator or generator + if sp.guidance_scale_provided: + guidance_scale = sp.guidance_scale + else: + # Distilled checkpoint is trained guidance-free (and the request layer coerces an explicit + # guidance_scale=0 to "not provided"), so default it to no CFG. + guidance_scale = 0.0 if self.is_distilled else guidance_scale + num_images_per_prompt = sp.num_outputs_per_prompt if sp.num_outputs_per_prompt > 0 else num_images_per_prompt + + multiple = self.vae_scale_factor * self.patch_size + if height % multiple != 0 or width % multiple != 0: + rounded_height = ((height + multiple - 1) // multiple) * multiple + rounded_width = ((width + multiple - 1) // multiple) * multiple + logger.warning( + "`height` and `width` must be multiples of %d; rounding up from %dx%d to %dx%d.", + multiple, + height, + width, + rounded_height, + rounded_width, + ) + height, width = rounded_height, rounded_width + + device = self.device + self._guidance_scale = guidance_scale + + if prompt is not None and isinstance(prompt, str): + batch_size = 1 + elif prompt is not None and isinstance(prompt, list): + batch_size = len(prompt) + else: + batch_size = 1 + + prompt_embeds, prompt_embeds_mask = self.encode_prompt( + prompt=prompt, + device=device, + num_images_per_prompt=num_images_per_prompt, + max_sequence_length=max_sequence_length, + ) + negative_prompt_embeds = None + negative_prompt_embeds_mask = None + if self.do_classifier_free_guidance: + if negative_prompt is None: + negative_prompt = "" + if isinstance(negative_prompt, str): + negative_prompt = [negative_prompt] * batch_size + negative_prompt_embeds, negative_prompt_embeds_mask = self.encode_prompt( + prompt=negative_prompt, + device=device, + num_images_per_prompt=num_images_per_prompt, + max_sequence_length=max_sequence_length, + ) + + num_channels_latents = self.transformer.in_channels // (self.patch_size**2) + latents = self.prepare_latents( + batch_size * num_images_per_prompt, + num_channels_latents, + height, + width, + prompt_embeds.dtype, + device, + generator, + latents, + ) + grid_height = height // (self.vae_scale_factor * self.patch_size) + grid_width = width // (self.vae_scale_factor * self.patch_size) + position_ids = self.prepare_position_ids(prompt_embeds.shape[1], grid_height, grid_width, device) + + sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps) if sigmas is None else sigmas + image_seq_len = latents.shape[1] + if self.is_distilled: + mu = 1.15 + else: + mu = calculate_shift( + image_seq_len, + self.scheduler.config.get("base_image_seq_len", 256), + self.scheduler.config.get("max_image_seq_len", 6400), + self.scheduler.config.get("base_shift", 0.5), + self.scheduler.config.get("max_shift", 1.15), + ) + timesteps, num_inference_steps = retrieve_timesteps( + self.scheduler, num_inference_steps, device, sigmas=sigmas, mu=mu + ) + + latents = self.diffuse( + latents=latents, + timesteps=timesteps, + position_ids=position_ids, + prompt_embeds=prompt_embeds, + prompt_embeds_mask=prompt_embeds_mask, + negative_prompt_embeds=negative_prompt_embeds, + negative_prompt_embeds_mask=negative_prompt_embeds_mask, + guidance_scale=guidance_scale, + ) + + if output_type == "latent": + image = latents + else: + latents = self._unpack_latents(latents, height, width) + latents = latents.to(self.vae.dtype) + latents_mean = ( + torch.tensor(self.vae.config.latents_mean) + .view(1, self.vae.config.z_dim, 1, 1, 1) + .to(latents.device, latents.dtype) + ) + latents_std = 1.0 / torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to( + latents.device, latents.dtype + ) + latents = latents / latents_std + latents_mean + image = self.vae.decode(latents, return_dict=False)[0][:, :, 0] + + return DiffusionOutput( + output=image, + 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) + loaded_weights = loader.load_weights(weights) + # VAE and text encoder load eagerly in __init__; record their params so strict load checks pass. + loaded_weights |= {f"vae.{name}" for name, _ in self.vae.named_parameters()} + if self.text_encoder is not None: + loaded_weights |= {f"text_encoder.{name}" for name, _ in self.text_encoder.named_parameters()} + return loaded_weights diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index 341e62ede..c8a5ee974 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -306,6 +306,11 @@ _DIFFUSION_MODELS = { "pipeline_sdxl", "StableDiffusionXLPipeline", ), + "Krea2Pipeline": ( + "krea2", + "pipeline_krea2", + "Krea2Pipeline", + ), } @@ -533,6 +538,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "Cosmos3OmniDiffusersPipeline": "get_cosmos3_post_process_func", "HiDreamImagePipeline": "get_hidream_image_post_process_func", "StableDiffusionXLPipeline": "get_sdxl_image_post_process_func", + "Krea2Pipeline": "get_krea2_post_process_func", } _DIFFUSION_ACTION_POST_PROCESS_FUNCS = { From 9014bb9917291e78a22f05214f3a37d9d6090d6e Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Sat, 11 Jul 2026 21:00:50 -0600 Subject: [PATCH 065/101] [CI] Fix core_model Level for non-Diffusion Models (#5008) --- tests/helpers/runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py index 6ca8d2608..885a4654f 100644 --- a/tests/helpers/runtime.py +++ b/tests/helpers/runtime.py @@ -2826,7 +2826,7 @@ def iter_omni_server( # be ideal to cleanup consistently everywhere. original_model = model_prefix + params.model model = original_model - if run_level == "core_model": + if run_level == "core_model" and request.node.get_closest_marker("diffusion"): model = resolve_tiny_model_path(model) port = params.port stage_config_path = stage_config_path_for_run_level(params.stage_config_path, run_level) @@ -2917,7 +2917,7 @@ def iter_omni_runner( extra_omni_kwargs = dict(extra) if extra is not None else {} stage_config_path = stage_config_path_for_run_level(stage_config_path, run_level) model = model_prefix + model - if run_level == "core_model": + if run_level == "core_model" and request.node.get_closest_marker("diffusion"): model = resolve_tiny_model_path(model) with OmniRunner(model, seed=42, stage_configs_path=stage_config_path, **extra_omni_kwargs) as runner: print("OmniRunner started successfully") From dda88a6f86447ff0168717b7328d0bf350c17df0 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:50:54 +0800 Subject: [PATCH 066/101] [BugFix] Align invalid layers DFX tests with 2-10 range (#5044) (#5047) Signed-off-by: wangyu <410167048@qq.com> --- .../invalid_param_test/test_invalid_image_editing.py | 4 ++-- .../invalid_param_test/test_invalid_image_generation.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_image_editing.py b/tests/dfx/reliability/invalid_param_test/test_invalid_image_editing.py index fa7f0f905..5ffd25632 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_image_editing.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_image_editing.py @@ -62,8 +62,8 @@ def _finalize_edits_form_data(template: dict[str, Any], server_model: str) -> di ), pytest.param( True, - {"prompt": "edit", "model": _IMAGE_EDITS_SERVER_MODEL, "layers": "2"}, - ("Invalid layers", "3", "1"), + {"prompt": "edit", "model": _IMAGE_EDITS_SERVER_MODEL, "layers": "1"}, + ("Invalid layers", "2", "10"), 400, id="invalid_layers", ), diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_image_generation.py b/tests/dfx/reliability/invalid_param_test/test_invalid_image_generation.py index 6f7cf984d..5e9424c88 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_image_generation.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_image_generation.py @@ -87,8 +87,8 @@ def _minimal_images_gen_json(omni_server: OmniServer) -> dict[str, object]: pytest.param( {"true_cfg_scale": 21.0}, ("true_cfg_scale", "less_than_equal", "20"), id="true_cfg_scale_above_max" ), - pytest.param({"layers": 2}, ("layers", "value_error", "3", "10"), id="layers_below_min"), - pytest.param({"layers": 11}, ("layers", "value_error", "3", "10"), id="layers_above_max"), + pytest.param({"layers": 1}, ("layers", "value_error", "2", "10"), id="layers_below_min"), + pytest.param({"layers": 11}, ("layers", "value_error", "2", "10"), id="layers_above_max"), pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative", marks=_SKIP_ISSUE_3649), pytest.param( {"seed": 2**32}, From d3c47efc4f818931f29e5f1567c94c454561631c Mon Sep 17 00:00:00 2001 From: Zhou Taichang Date: Sun, 12 Jul 2026 23:39:53 +0800 Subject: [PATCH 067/101] [Rebase] Rebase to vllm v0.25.0 (#5042) Signed-off-by: tzhouam Co-authored-by: Claude Opus 4.8 --- docker/Dockerfile.ci | 2 +- docs/getting_started/installation/gpu.md | 2 +- .../installation/gpu/cuda.inc.md | 16 ++--- .../installation/gpu/rocm.inc.md | 14 ++-- docs/getting_started/quickstart.md | 6 +- tests/ar_diffusion/test_kv_cache.py | 12 +--- .../test_async_omni_engine_stage_init.py | 51 ++++++++++++++ .../test_qwen3_omni_realtime_websocket.py | 20 +++++- .../openai_api/test_serving_chat_speaker.py | 4 +- .../entrypoints/test_stream_finish_reason.py | 2 +- .../voxcpm2/test_talker_state_eviction.py | 24 +++++-- vllm_omni/core/sched/omni_ar_scheduler.py | 21 ++++-- .../core/sched/omni_generation_scheduler.py | 22 +++--- .../model_loader/diffusers_loader.py | 65 +++++++++++++++++- .../gr00t/modeling/processing_gr00t_n1d7.py | 8 ++- vllm_omni/engine/async_omni_engine.py | 12 ++-- vllm_omni/engine/output_processor.py | 18 +++++ vllm_omni/engine/stage_engine_startup.py | 58 +++++++++++++++- vllm_omni/entrypoints/openai/api_server.py | 46 ++++++------- .../openai/serving_audio_generate.py | 2 +- vllm_omni/entrypoints/openai/serving_chat.py | 21 +++++- .../entrypoints/openai/serving_speech.py | 2 +- .../ar_diffusion/kv_cache/paged.py | 17 +---- vllm_omni/patch.py | 67 +++++++++++++++++++ vllm_omni/worker/gpu_ar_model_runner.py | 12 ++++ 25 files changed, 415 insertions(+), 109 deletions(-) diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci index 8b19ed678..f4444f8ac 100644 --- a/docker/Dockerfile.ci +++ b/docker/Dockerfile.ci @@ -2,7 +2,7 @@ # The pinned VLLM_PRECOMPILED_WHEEL_COMMIT and VLLM_BASE_TAG below must # be updated on every rebase to match the new upstream vllm commit/tag. ARG VLLM_BASE_IMAGE=vllm/vllm-openai -ARG VLLM_BASE_TAG=v0.24.0 +ARG VLLM_BASE_TAG=v0.25.0 FROM ${VLLM_BASE_IMAGE}:${VLLM_BASE_TAG} ARG APP_DIR=/workspace/vllm-omni WORKDIR ${APP_DIR} diff --git a/docs/getting_started/installation/gpu.md b/docs/getting_started/installation/gpu.md index 4ef35398c..e21a11cee 100644 --- a/docs/getting_started/installation/gpu.md +++ b/docs/getting_started/installation/gpu.md @@ -34,7 +34,7 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr ### Pre-built wheels -Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, and 0.24.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). +Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, and 0.25.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). === "NVIDIA CUDA" diff --git a/docs/getting_started/installation/gpu/cuda.inc.md b/docs/getting_started/installation/gpu/cuda.inc.md index 83cbcd68d..d4808139e 100644 --- a/docs/getting_started/installation/gpu/cuda.inc.md +++ b/docs/getting_started/installation/gpu/cuda.inc.md @@ -20,7 +20,7 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new** vLLM-Omni is built based on vLLM. Please install it with command below. ```bash -uv pip install vllm==0.24.0 --torch-backend=auto +uv pip install vllm==0.25.0 --torch-backend=auto ``` #### Installation of vLLM-Omni @@ -39,13 +39,13 @@ uv pip install 'vllm-omni[demo]' # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.24.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.25.0 release version of the library ```bash -uv pip install vllm==0.24.0 --torch-backend=auto +uv pip install vllm==0.25.0 --torch-backend=auto ``` -The 0.24.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.25.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -66,12 +66,12 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.24.0 +git checkout v0.25.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.24.0; the wheel filename has no `+cu130` suffix) -export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.24.0/vllm-0.24.0-cp38-abi3-manylinux_2_28_x86_64.whl +#For CUDA 13.0 (the default for v0.25.0; the wheel filename has no `+cu130` suffix) +export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.25.0/vllm-0.25.0-cp38-abi3-manylinux_2_28_x86_64.whl ``` Install vllm with command below (If you have no existing PyTorch). ```bash @@ -102,7 +102,7 @@ docker run --runtime nvidia --gpus 2 \ --env "HF_TOKEN=$HF_TOKEN" \ -p 8091:8091 \ --ipc=host \ - vllm/vllm-omni:v0.24.0 \ + vllm/vllm-omni:v0.25.0 \ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 ``` diff --git a/docs/getting_started/installation/gpu/rocm.inc.md b/docs/getting_started/installation/gpu/rocm.inc.md index af88e1e0c..4a64e8723 100644 --- a/docs/getting_started/installation/gpu/rocm.inc.md +++ b/docs/getting_started/installation/gpu/rocm.inc.md @@ -13,7 +13,7 @@ vLLM-Omni current recommends the steps in under setup through Docker Images. vLLM-Omni is built based on vLLM. Please install it with command below. ```bash -uv pip install vllm==0.24.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.24.0/rocm722 +uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 ``` #### Installation of vLLM-Omni @@ -34,13 +34,13 @@ uv pip install onnxruntime-rocm # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.24.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.25.0 release version of the library ```bash -uv pip install vllm==0.24.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.24.0/rocm722 +uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 ``` -The pre-built 0.24.0 vLLM wheel targets ROCm 7.2.2. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. +The pre-built 0.25.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -58,7 +58,7 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.24.0 +git checkout v0.25.0 python3 -m pip install -r requirements/rocm.txt python3 setup.py develop ``` @@ -130,7 +130,7 @@ docker run --rm \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=$HF_TOKEN" \ -p 8091:8091 \ - vllm/vllm-omni-rocm:v0.24.0 \ + vllm/vllm-omni-rocm:v0.25.0 \ --model Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 ``` @@ -149,7 +149,7 @@ docker run --rm -it \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=$HF_TOKEN" \ --entrypoint bash \ - vllm/vllm-omni-rocm:v0.24.0 + vllm/vllm-omni-rocm:v0.25.0 ``` # --8<-- [end:pre-built-images] diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 2c7f278ca..914f1f7b4 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -19,10 +19,10 @@ uv venv --python 3.12 --seed source .venv/bin/activate # On CUDA -uv pip install vllm==0.24.0 --torch-backend=auto +uv pip install vllm==0.25.0 --torch-backend=auto # On ROCm -uv pip install vllm==0.24.0+rocm722 --extra-index-url https://wheels.vllm.ai/rocm/0.24.0/rocm722 +uv pip install vllm==0.25.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.25.0/rocm723 git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni @@ -34,7 +34,7 @@ For additional installation methods — please see the [installation guide](inst !!! note It is important to install the same major & minor version of vLLM and vLLM Omni, otherwise things may not work as expected. If the versions are misaligned, you will see a warning when you import vLLM Omni. - If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.24.0` and vLLM Omni `0.24.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. + If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.25.0` and vLLM Omni `0.25.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. ## Offline Inference diff --git a/tests/ar_diffusion/test_kv_cache.py b/tests/ar_diffusion/test_kv_cache.py index cbb9656e5..beb575a0b 100644 --- a/tests/ar_diffusion/test_kv_cache.py +++ b/tests/ar_diffusion/test_kv_cache.py @@ -9,14 +9,9 @@ KV stack on CPU (block bookkeeping only, no GPU tensors). import pytest import torch from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind +from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry from vllm.v1.request import RequestStatus -try: - from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry -except ModuleNotFoundError: - KVCacheSpecRegistry = None - from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map - from vllm_omni.experimental.ar_diffusion.kv_cache import ( ARDiffusionKVCache, ARDiffusionKVConfig, @@ -53,10 +48,7 @@ def test_spec_registration_resolves_to_chunk_window_manager(): # Without explicit registration the MRO walk would fall back to the parent # SlidingWindowManager; assert the subclass manager wins. spec = make_spec() - if KVCacheSpecRegistry is not None: - assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager - else: - assert spec_manager_map[type(spec)] is ChunkWindowManager + assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager def test_spec_kind_is_sliding_window(): diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index ff467dc65..d5a50cf29 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -874,3 +874,54 @@ def test_build_engine_args_dict_uses_diffusion_attention_config_key(): assert "attention_config" not in engine_args_dict assert engine_args_dict["diffusion_attention_config"].default.backend == "FLASH_ATTN" + + +def test_omni_master_server_allocates_globally_unique_route_ports(monkeypatch): + """Regression: two stages must never draw the same ZMQ port. + + ``get_open_ports_list`` only dedups within a single call, so per-route + allocation used to let a later stage reuse an earlier stage's port. The + second engine to ``bind()`` then died with ``zmq.error.ZMQError: Address + already in use`` (flaky multi-stage startup, e.g. Qwen3-Omni thinker/talker/ + code2wav). ``OmniMasterServer`` now dedups every port it hands out. + """ + import itertools + + from vllm_omni.engine import stage_engine_startup as ses + + # A colliding prefix (repeats + the master port 9000) followed by an endless + # fresh stream, so the only way to succeed is to redraw the collisions. + supply = itertools.chain([9000, 9000, 9001, 9001, 9000], itertools.count(9002)) + + def fake_get_open_ports_list(count): + return [next(supply) for _ in range(count)] + + monkeypatch.setattr(ses, "get_open_ports_list", fake_get_open_ports_list) + + server = ses.OmniMasterServer( + master_address="127.0.0.1", + master_port=9000, # seed: a route must not reuse the registration port + stage_ids=[0, 1, 2], + ) + + ports = [] + for sid in (0, 1, 2): + alloc = server.get_allocation(sid) + for addr in ( + alloc.handshake_bind_address, + alloc.input_bind_address, + alloc.output_bind_address, + ): + ports.append(ses._port_from_zmq_address(addr)) + + assert len(ports) == len(set(ports)), f"duplicate route ports allocated: {ports}" + assert 9000 not in ports, "route reused the master registration port" + + +def test_port_from_zmq_address_parsing(): + from vllm_omni.engine.stage_engine_startup import _port_from_zmq_address + + assert _port_from_zmq_address("tcp://127.0.0.1:34277") == 34277 + assert _port_from_zmq_address(None) is None + assert _port_from_zmq_address("ipc:///tmp/sock") is None + assert _port_from_zmq_address("tcp://host:not-a-port") is None diff --git a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py index 15e257959..50f8eef91 100644 --- a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py +++ b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py @@ -198,12 +198,28 @@ def _assert_realtime_smoke(result: dict) -> None: assert result["output_sample_rate"] > 0 -def _assert_realtime_accuracy(result: dict) -> None: +def _assert_realtime_accuracy( + result: dict, + whisper_model_size: str = "large-v3", +) -> None: + """Assert that whisper transcription of audio output matches model text. + + Args: + result: Roundtrip result dict from ``_run_realtime_audio_roundtrip``. + whisper_model_size: Whisper model used to transcribe the generated audio + for the accuracy check. Defaults to ``large-v3``: the default + ``small`` model mishears short Chinese TTS clips (observed: + 北京→韦京 and a dropped leading sentence, sim=0.443), which + caused spurious sim<0.8 failures under async_chunk codec + variability even though audio generation was correct. large-v3 + transcribes these clips reliably, so a failure here now points + at the model, not the ASR grader. + """ final_text = (result["transcription_text"] or "").strip() assert final_text, "Expected non-empty transcription (model text stream)" wav_out = _wav_bytes_from_pcm16(result["output_pcm"], result["output_sample_rate"]) - whisper_text = convert_audio_bytes_to_text(wav_out).strip() + whisper_text = convert_audio_bytes_to_text(wav_out, model_size=whisper_model_size).strip() assert whisper_text, "Whisper returned empty string for synthesized output audio" sim = cosine_similarity_text(whisper_text.lower(), final_text.lower()) diff --git a/tests/entrypoints/openai_api/test_serving_chat_speaker.py b/tests/entrypoints/openai_api/test_serving_chat_speaker.py index 27839ee23..b0977c0c2 100644 --- a/tests/entrypoints/openai_api/test_serving_chat_speaker.py +++ b/tests/entrypoints/openai_api/test_serving_chat_speaker.py @@ -87,8 +87,8 @@ def test_create_chat_completion_converts_value_error_to_error_response(mocker: M serving_chat.chat_template = None serving_chat.chat_template_content_format = "string" serving_chat.default_chat_template_kwargs = {} - serving_chat.openai_serving_render = mocker.MagicMock() - serving_chat.openai_serving_render.validate_chat_template.return_value = None + serving_chat.online_renderer = mocker.MagicMock() + serving_chat.online_renderer.validate_chat_template.return_value = None serving_chat._effective_chat_template_kwargs = mocker.MagicMock(return_value={}) serving_chat._preprocess_chat = mocker.AsyncMock( side_effect=ValueError("Invalid speaker 'uncle_fu'. Supported: ethan, vivian") diff --git a/tests/entrypoints/test_stream_finish_reason.py b/tests/entrypoints/test_stream_finish_reason.py index 52cc32215..ebbca1917 100644 --- a/tests/entrypoints/test_stream_finish_reason.py +++ b/tests/entrypoints/test_stream_finish_reason.py @@ -144,7 +144,7 @@ def _build_serving_chat(): engine_client=mock_engine, models=models, response_role="assistant", - openai_serving_render=mock_render, + online_renderer=mock_render, request_logger=None, chat_template=None, chat_template_content_format="auto", diff --git a/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py b/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py index 28da0b1bc..f5a94376a 100644 --- a/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py +++ b/tests/model_executor/models/voxcpm2/test_talker_state_eviction.py @@ -52,6 +52,10 @@ def _make_bare_talker(): talker._cfm_buffers = None talker._last_audio_output_req_ids = [] talker._batched_fsq_fusion_max_batch = 32 + # Added in VoxCPM2TalkerForConditionalGeneration.__init__; set to + # default (False) to avoid AttributeError when test exercises forward + # path that checks this flag. + talker._enable_unified_decode_graph = False return talker @@ -329,23 +333,27 @@ class TestDecodeBatchContract: for (expected_res, expected_meta), (actual_res, actual_meta) in zip(expected, actual, strict=True): torch.testing.assert_close(actual_res, expected_res) - torch.testing.assert_close(actual_meta["new_lm_hidden"], expected_meta["new_lm_hidden"]) + torch.testing.assert_close(actual_meta.new_lm_hidden, expected_meta.new_lm_hidden) assert [state.decode_step_count for state in batch_states] == [1, 1] def test_batch_decode_matches_per_request_state_updates(self) -> None: + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( + _DecodeResidualMeta, + ) + seq_talker = self._make_decode_talker() batch_talker = self._make_decode_talker() seq_states = [self._make_decode_state("req-0", 0.0), self._make_decode_state("req-1", 10.0)] batch_states = [self._make_decode_state("req-0", 0.0), self._make_decode_state("req-1", 10.0)] metas = [ - {"new_lm_hidden": torch.tensor([[1.0, 2.0, 3.0, 4.0]])}, - {"new_lm_hidden": torch.tensor([[5.0, 6.0, 7.0, 8.0]])}, + _DecodeResidualMeta(new_lm_hidden=torch.tensor([[1.0, 2.0, 3.0, 4.0]])), + _DecodeResidualMeta(new_lm_hidden=torch.tensor([[5.0, 6.0, 7.0, 8.0]])), ] batch_out = torch.tensor([[0.5, 1.0, 1.5, 2.0], [2.5, 3.0, 3.5, 4.0]]) for state, meta, res_out in zip(seq_states, metas, batch_out, strict=True): - seq_talker._finish_decode(state, meta, res_out.unsqueeze(0), torch.device("cpu")) + seq_talker._finish_decode(state, meta, res_out.unsqueeze(0)) batch_talker._finish_decode_batch( [(state, False, meta) for state, meta in zip(batch_states, metas, strict=True)], @@ -360,12 +368,16 @@ class TestDecodeBatchContract: torch.testing.assert_close(actual.last_audio_patch_gpu, expected.last_audio_patch_gpu) def test_batch_decode_preserves_per_request_cfm_rng_order(self) -> None: + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( + _DecodeResidualMeta, + ) + talker = self._make_decode_talker() talker._enable_batched_cfm = False states = [self._make_decode_state("req-0", 0.0), self._make_decode_state("req-1", 10.0)] metas = [ - {"new_lm_hidden": torch.tensor([[1.0, 2.0, 3.0, 4.0]])}, - {"new_lm_hidden": torch.tensor([[5.0, 6.0, 7.0, 8.0]])}, + _DecodeResidualMeta(new_lm_hidden=torch.tensor([[1.0, 2.0, 3.0, 4.0]])), + _DecodeResidualMeta(new_lm_hidden=torch.tensor([[5.0, 6.0, 7.0, 8.0]])), ] batch_out = torch.tensor([[0.5, 1.0, 1.5, 2.0], [2.5, 3.0, 3.5, 4.0]]) diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index 217c81543..74d09ab60 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -314,13 +314,6 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): outputs: dict[int, list[EngineCoreOutput]] = defaultdict(list) spec_decoding_stats: SpecDecodingStats | None = None - kv_connector_stats: KVConnectorStats | None = ( - kv_connector_output.kv_connector_stats if kv_connector_output else None - ) - if kv_connector_stats and self.connector: - kv_stats = self.connector.get_kv_connector_stats() - if kv_stats: - kv_connector_stats = kv_connector_stats.aggregate(kv_stats) failed_kv_load_req_ids = None if kv_connector_output and kv_connector_output.invalid_block_ids: @@ -563,6 +556,20 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): if kv_connector_output: self._update_from_kv_xfer_finished(kv_connector_output) + # Worker-side KV connector stats from the model runner output. + kv_connector_stats: KVConnectorStats | None = ( + kv_connector_output.kv_connector_stats if kv_connector_output else None + ) + if self.connector: + # Scheduler-side KV connector stats collected after connector update. + scheduler_kv_connector_stats = self.connector.get_kv_connector_stats() + if scheduler_kv_connector_stats is not None and not scheduler_kv_connector_stats.is_empty(): + kv_connector_stats = ( + kv_connector_stats.aggregate(scheduler_kv_connector_stats) + if kv_connector_stats is not None + else scheduler_kv_connector_stats + ) + # collect KV cache events from KV cache manager events = self.kv_cache_manager.take_events() diff --git a/vllm_omni/core/sched/omni_generation_scheduler.py b/vllm_omni/core/sched/omni_generation_scheduler.py index 42caea252..973b8c55a 100644 --- a/vllm_omni/core/sched/omni_generation_scheduler.py +++ b/vllm_omni/core/sched/omni_generation_scheduler.py @@ -431,14 +431,6 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler): outputs: dict[int, list[EngineCoreOutput]] = defaultdict(list) spec_decoding_stats: SpecDecodingStats | None = None - kv_connector_stats: KVConnectorStats | None = ( - kv_connector_output.kv_connector_stats if kv_connector_output else None - ) - # Merge connector-side stats (align with v0.14.0) - if kv_connector_stats and self.connector: - kv_stats = self.connector.get_kv_connector_stats() - if kv_stats: - kv_connector_stats = kv_connector_stats.aggregate(kv_stats) failed_kv_load_req_ids = None if kv_connector_output and getattr(kv_connector_output, "invalid_block_ids", None): @@ -646,6 +638,20 @@ class OmniGenerationScheduler(OmniSchedulerMixin, VLLMScheduler): if kv_connector_output: self._update_from_kv_xfer_finished(kv_connector_output) + # Worker-side KV connector stats from the model runner output. + kv_connector_stats: KVConnectorStats | None = ( + kv_connector_output.kv_connector_stats if kv_connector_output else None + ) + if self.connector: + # Scheduler-side KV connector stats collected after connector update. + scheduler_kv_connector_stats = self.connector.get_kv_connector_stats() + if scheduler_kv_connector_stats is not None and not scheduler_kv_connector_stats.is_empty(): + kv_connector_stats = ( + kv_connector_stats.aggregate(scheduler_kv_connector_stats) + if kv_connector_stats is not None + else scheduler_kv_connector_stats + ) + # Collect and publish KV cache events (align with v0.14.0) events = self.kv_cache_manager.take_events() if self.connector is not None: diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index 943c55a52..d8b9c90c5 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -392,16 +392,77 @@ class DiffusersPipelineLoader: return model.eval() + @staticmethod + def _has_online_quant(model: nn.Module) -> bool: + """Whether any layer uses an online-quant method that defers weight + materialization onto the ``meta`` device (upstream vLLM + ``uses_meta_device=True``, e.g. online FP8).""" + for module in model.modules(): + quant_method = getattr(module, "quant_method", None) + if getattr(quant_method, "uses_meta_device", False): + return True + return False + def _process_weights_after_loading(self, model: nn.Module, target_device: torch.device) -> None: """Process weights after loading for quantization methods. This handles vLLM's quantization methods that need to process weights after loading (e.g., FP8 online quantization from BF16/FP16 weights). """ + # Newer upstream vLLM online-quant methods (uses_meta_device=True) create + # weights on the ``meta`` device and materialize them just-in-time as each + # layer's weights finish loading (via the layerwise online-process loader). + # Any "straggler" layers whose weights were not fully materialized during + # load (padded / partially-loaded layers) remain on ``meta``. Upstream's + # base_loader calls finalize_layerwise_processing() to materialize them; + # the diffusion loader must mirror that, otherwise the module.to() below + # raises "Cannot copy out of meta tensor; no data!". This whole meta-device + # handling is gated on online quant actually being in use, so that the + # proven code path for everything else (in particular FSDP/HSDP-sharded + # params, whose per-parameter .data cannot be cross-device reassigned) is + # left untouched. Import lazily so older vLLM (no meta-device quant) is + # unaffected. + has_online_quant = self._has_online_quant(model) + if has_online_quant: + from vllm.model_executor.model_loader.reload.layerwise import ( + finalize_layerwise_processing, + ) + + # model_config is only dereferenced by finalize for vLLM Attention / + # MLAAttention layers; diffusion DiT models use their own attention and + # have none, so passing None is safe here. + finalize_layerwise_processing(model, model_config=None) + for _, module in model.named_modules(): quant_method = getattr(module, "quant_method", None) - if quant_method is not None and isinstance(quant_method, QuantizeMethodBase): - # Move module to target device for processing if needed + if quant_method is None or not isinstance(quant_method, QuantizeMethodBase): + continue + + if has_online_quant: + # Online quant may leave straggler params on the ``meta`` device. + # Move only real (non-meta) params onto the target device for + # processing and restore them afterward, mirroring upstream vLLM's + # device_loading_context — a blanket module.to(target_device) would + # raise NotImplementedError on meta params. Online quant initializes + # on the accelerator, so params are normally already on the target + # device and this loop is a no-op move; the point is to skip meta. + original_devices: dict[str, torch.device] = {} + for name, param in module.named_parameters(): + if param.device.type != "meta" and param.device != target_device: + original_devices[name] = param.device + param.data = param.data.to(target_device) + + quant_method.process_weights_after_loading(module) + + # Restore pre-existing params to their original device; leave any + # newly created (e.g. quantized) params on the target device. + for name, param in module.named_parameters(): + if name in original_devices: + param.data = param.data.to(original_devices[name]) + else: + # No meta params possible here. Preserve the original FSDP/HSDP-aware + # whole-module move (module.to()), which correctly handles sharded + # DTensor params that per-parameter .data reassignment cannot. module_device = next(module.parameters(), None) if module_device is not None: module_device = module_device.device diff --git a/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py b/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py index ba128f450..ade5eac83 100644 --- a/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py +++ b/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py @@ -19,6 +19,7 @@ from transformers.utils import cached_file from vllm.logger import init_logger from vllm_omni.diffusion.models.gr00t.configs.embodiment_configs import ModalityConfig +from vllm_omni.diffusion.models.gr00t.configs.gr00t_n1d7 import Gr00tN1d7Config from vllm_omni.diffusion.models.gr00t.dataio.embodiment_tags import EmbodimentTag from vllm_omni.diffusion.models.gr00t.dataio.state_action.state_action_processor import StateActionProcessor from vllm_omni.diffusion.models.gr00t.dataio.utils import parse_modality_configs, to_json_serializable @@ -552,4 +553,9 @@ class Gr00tN1d7Processor(ProcessorMixin): return cls(**processor_kwargs, transformers_loading_kwargs=transformers_loading_kwargs) -AutoProcessor.register("Gr00tN1d7", Gr00tN1d7Processor) +# transformers >= 5.5 requires the config *class* (not a model_type string) as the +# first arg to AutoProcessor.register — it does `key.__module__` internally. Passing +# the string "Gr00tN1d7" raises AttributeError: 'str' object has no attribute +# '__module__'. Match the AutoModel.register(Gr00tN1d7Config, ...) call in +# modeling/gr00t_n1d7.py. +AutoProcessor.register(Gr00tN1d7Config, Gr00tN1d7Processor) diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index ec9a9698c..ce9662884 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -1568,16 +1568,18 @@ class AsyncOmniEngine: # and can cause CUDA OOM for subsequent engine instances (especially # large models like BAGEL-7B-MoT whose weights alone consume ~134 GiB). # - # Discard mode (level=2) is correct at shutdown: there is no benefit to - # keeping a CPU backup when the engine is being torn down. + # CuMemAllocator.sleep() is NOT idempotent — calling it on already- + # slept entries causes CUDA_ERROR_INVALID_VALUE at cumem_allocator + # cuMemRelease (double-free of the memory handle). Use release_pools() + # instead, which is the designed cleanup path: it drops MemPool refs + # and lets the destructor/free path handle asleep entries correctly + # (returns a null handle so the C extension skips unmap/release). try: from vllm.device_allocator.cumem import CuMemAllocator, cumem_available if cumem_available: allocator = CuMemAllocator.get_instance() - # Sleep at level 2 discards all pool memory from the GPU - # without creating CPU backups — cheapest and fastest. - allocator.sleep() + allocator.release_pools() logger.debug("[AsyncOmniEngine] Released CuMem memory pool during shutdown") except Exception: pass diff --git a/vllm_omni/engine/output_processor.py b/vllm_omni/engine/output_processor.py index c9044e29e..71303269f 100644 --- a/vllm_omni/engine/output_processor.py +++ b/vllm_omni/engine/output_processor.py @@ -28,6 +28,12 @@ from vllm_omni.outputs import OmniRequestOutput logger = init_logger(__name__) +# Keys whose values may arrive as scalar tensors (e.g. sample rate) but +# represent metadata, not concatenatable data. When these appear in +# ``MultimodalPayload.tensors`` they must be relocated to ``.metadata`` +# before consolidation so the tensor concatenation path does not attempt +# a dimensioned cat on a 0-d tensor. +_METADATA_TENSOR_KEYS: frozenset[str] = frozenset({"sr", "sample_rate", "audio_sample_rate"}) # --------------------------------------------------------------------------- # Module-level helpers @@ -212,6 +218,18 @@ class OmniRequestState(RequestState): modality = OutputModality.TEXT strategy = get_accumulation_strategy(modality) + # Move known metadata scalar tensors (e.g. sample rate) from + # tensors to metadata so they are handled by the REPLACE path + # below instead of failing tensor concatenation. + for key in _METADATA_TENSOR_KEYS: + v = self.mm_accumulated.tensors.pop(key, None) + if v is not None: + if isinstance(v, list): + # Keep the last value (REPLACE semantics) + self.mm_accumulated.metadata[key] = v[-1] + else: + self.mm_accumulated.metadata[key] = v + try: for k, v in list(self.mm_accumulated.tensors.items()): if isinstance(v, list) and v and isinstance(v[0], torch.Tensor): diff --git a/vllm_omni/engine/stage_engine_startup.py b/vllm_omni/engine/stage_engine_startup.py index 8f6c199ec..7a55bb93f 100644 --- a/vllm_omni/engine/stage_engine_startup.py +++ b/vllm_omni/engine/stage_engine_startup.py @@ -138,6 +138,21 @@ class StageReplicaResources: # --------------------------------------------------------------------------- +def _port_from_zmq_address(address: str | None) -> int | None: + """Extract the TCP port from a ``tcp://host:port`` ZMQ address, else ``None``. + + Non-TCP transports (``ipc://``, ``inproc://``) and unparsable values return + ``None`` so callers can skip seeding them into the port-dedup set. + """ + if not address: + return None + tail = address.rsplit(":", 1)[-1] + try: + return int(tail) + except ValueError: + return None + + class OmniMasterServer: """Registration server for single-stage engine startup.""" @@ -158,10 +173,25 @@ class OmniMasterServer: self._stage_configs: dict[StageRoute, Any] = {} self._stage_coordinator_addresses: dict[StageRoute, StageCoordinatorAddresses] = {} self._stage_config_events: dict[StageRoute, threading.Event] = {} + # Ports already handed out by *this* server. ``get_open_ports_list`` only + # guarantees uniqueness *within* a single call; a per-route call cannot + # see ports drawn for other routes, so two stages/replicas can draw the + # same ephemeral port and the second engine to ``bind()`` it dies with + # ``zmq.error.ZMQError: Address already in use``. Multi-stage models + # (e.g. Qwen3-Omni: thinker/talker/code2wav) allocate many routes and hit + # this intermittently. We dedup every allocated port against this set, + # seeded with the registration port (and coordinator ROUTER port) that + # are already bound on the same host. + self._allocated_ports: set[int] = set() + if master_port: + self._allocated_ports.add(int(master_port)) # Coordinator ROUTER address echoed back in every registration reply # so OmniCoordClientForStage knows where to connect from inside the # engine subprocess. self._coordinator_router_address = coordinator_router_address + coord_port = _port_from_zmq_address(coordinator_router_address) + if coord_port is not None: + self._allocated_ports.add(coord_port) # Fires only for *newly assigned* (auto-assigned) replicas, not for # head-side pre-allocated slots that already have head-side clients. self._on_register = on_register @@ -235,7 +265,7 @@ class OmniMasterServer: self._stage_config_events[route] = threading.Event() self._stage_coordinator_addresses[route] = StageCoordinatorAddresses() - hs_port, inp_port, out_port = get_open_ports_list(count=3) + hs_port, inp_port, out_port = self._alloc_unique_ports(3) alloc = StageAllocation( handshake_bind_address=f"tcp://{self._address}:{hs_port}", handshake_connect_address=f"tcp://{self._address}:{hs_port}", @@ -247,6 +277,32 @@ class OmniMasterServer: self._stage_routes[route] = alloc return alloc + def _alloc_unique_ports(self, count: int) -> list[int]: + """Return ``count`` open ports unique across every route this server owns. + + ``get_open_ports_list`` dedups only within its own call, so we redraw any + port that collides with one already recorded in ``self._allocated_ports`` + (registration/coordinator ports plus every previously-allocated route + port) and register the winners before returning. Callers already hold the + relevant allocation context (``__init__`` is single-threaded; + registration holds ``self._alloc_lock``). + """ + picked: list[int] = [] + # Bounded retry budget so a pathological host (near port exhaustion) + # fails loudly instead of spinning forever. + for _ in range(64): + for port in get_open_ports_list(count=count - len(picked)): + if port in self._allocated_ports: + continue + self._allocated_ports.add(port) + picked.append(port) + if len(picked) == count: + return picked + raise RuntimeError( + f"[OmniMasterServer] Could not allocate {count} unique open ports " + f"(host={self._address}); {len(self._allocated_ports)} already in use." + ) + def _next_free_replica_id(self, stage_id: int) -> int: """Return the next replica id to assign for an auto-assign registration. diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 7e1153617..a9251a8d8 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -32,7 +32,7 @@ from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send from vllm.engine.protocol import EngineClient from vllm.entrypoints.anthropic.serving import AnthropicServingMessages -from vllm.entrypoints.chat_utils import load_chat_template +from vllm.entrypoints.chat_utils import ChatTemplateConfig, load_chat_template from vllm.entrypoints.launcher import serve_http, terminate_if_errored from vllm.entrypoints.mcp.tool_server import DemoToolServer, MCPToolServer, ToolServer from vllm.entrypoints.openai.api_server import build_app as build_openai_app @@ -60,12 +60,11 @@ from vllm.entrypoints.pooling.classify.serving import ServingClassification from vllm.entrypoints.pooling.embed.serving import ServingEmbedding as OpenAIServingEmbedding from vllm.entrypoints.pooling.pooling.serving import ServingPooling from vllm.entrypoints.pooling.scoring.serving import ServingScores -from vllm.entrypoints.serve.disagg.serving import ServingTokens +from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens # vLLM moved `base` from openai.basic.api_router to serve.instrumentator.basic. # Keep a fallback for older/newer upstream layouts during rebase windows. from vllm.entrypoints.serve.instrumentator.basic import base -from vllm.entrypoints.serve.render.serving import OpenAIServingRender from vllm.entrypoints.serve.tokenize.serving import ServingTokenization from vllm.entrypoints.serve.utils.api_utils import ( load_aware_call, @@ -85,6 +84,7 @@ from vllm.entrypoints.speech_to_text.translation.serving import ( OpenAIServingTranslation, ) from vllm.logger import init_logger +from vllm.renderers.online_renderer import OnlineRenderer from vllm.tasks import POOLING_TASKS from vllm.tool_parsers import ToolParserManager from vllm.utils import random_uuid @@ -451,7 +451,7 @@ async def omni_run_server(args, **uvicorn_kwargs) -> None: # Add process-specific prefix to stdout and stderr. decorate_logs("APIServer", skip_if_decorated=True) - listen_address, sock = setup_openai_server(args) + listen_address, sock = setup_openai_server(args, reuse_port=False) # Unified use of omni_run_server_worker, AsyncOmni automatically handles LLM and Diffusion models await omni_run_server_worker(listen_address, sock, args, **uvicorn_kwargs) @@ -830,6 +830,12 @@ async def omni_init_app_state( if tokenizer is None or getattr(tokenizer, "chat_template", None) is None: resolved_chat_template = _load_model_chat_template_json(args.model) + chat_template_config = ChatTemplateConfig( + chat_template=resolved_chat_template, + chat_template_content_format=args.chat_template_content_format, + trust_request_chat_template=args.trust_request_chat_template, + ) + if args.tool_server == "demo": tool_server: ToolServer | None = DemoToolServer() assert isinstance(tool_server, DemoToolServer) @@ -895,11 +901,10 @@ async def omni_init_app_state( await state.openai_serving_models.init_static_loras() # NOTE: kept aligned with upstream `init_app_state`: - # Use OpenAIServingRender (replaces the old OnlineRenderer + OnlineDerenderer + ServingRender split). - state.openai_serving_render = OpenAIServingRender( + # Use OnlineRenderer (replaced OpenAIServingRender which was removed upstream). + state.online_renderer = OnlineRenderer( model_config=engine_client.model_config, renderer=engine_client.renderer, - model_registry=state.openai_serving_models.registry, request_logger=request_logger, chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, @@ -916,7 +921,7 @@ async def omni_init_app_state( OpenAIServingResponses( engine_client, state.openai_serving_models, - openai_serving_render=state.openai_serving_render, + state.online_renderer, request_logger=request_logger, chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, @@ -937,7 +942,7 @@ async def omni_init_app_state( engine_client, state.openai_serving_models, args.response_role, - openai_serving_render=state.openai_serving_render, + online_renderer=state.online_renderer, request_logger=request_logger, chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, @@ -964,7 +969,7 @@ async def omni_init_app_state( OpenAIServingCompletion( engine_client, state.openai_serving_models, - openai_serving_render=state.openai_serving_render, + online_renderer=state.online_renderer, request_logger=request_logger, return_tokens_as_token_ids=args.return_tokens_as_token_ids, enable_prompt_tokens_details=args.enable_prompt_tokens_details, @@ -979,9 +984,7 @@ async def omni_init_app_state( state.openai_serving_models, supported_tasks=tuple(supported_tasks), request_logger=request_logger, - chat_template=resolved_chat_template, - chat_template_content_format=args.chat_template_content_format, - trust_request_chat_template=args.trust_request_chat_template, + chat_template_config=chat_template_config, ) if any(task in POOLING_TASKS for task in supported_tasks) else None @@ -991,9 +994,7 @@ async def omni_init_app_state( engine_client, state.openai_serving_models, request_logger=request_logger, - chat_template=resolved_chat_template, - chat_template_content_format=args.chat_template_content_format, - trust_request_chat_template=args.trust_request_chat_template, + chat_template_config=chat_template_config, ) if "embed" in supported_tasks else None @@ -1003,9 +1004,7 @@ async def omni_init_app_state( engine_client, state.openai_serving_models, request_logger=request_logger, - chat_template=resolved_chat_template, - chat_template_content_format=args.chat_template_content_format, - trust_request_chat_template=args.trust_request_chat_template, + chat_template_config=chat_template_config, ) if "classify" in supported_tasks else None @@ -1014,8 +1013,9 @@ async def omni_init_app_state( ServingScores( engine_client, state.openai_serving_models, + supported_tasks=tuple(supported_tasks), request_logger=request_logger, - score_template=resolved_chat_template, + chat_template_config=chat_template_config, log_error_stack=args.log_error_stack, ) if any(t in supported_tasks for t in ("embed", "score", "token_embed")) @@ -1023,7 +1023,7 @@ async def omni_init_app_state( ) state.serving_tokenization = ServingTokenization( state.openai_serving_models, - state.openai_serving_render, + state.online_renderer, request_logger=request_logger, chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, @@ -1055,7 +1055,7 @@ async def omni_init_app_state( engine_client, state.openai_serving_models, args.response_role, - openai_serving_render=state.openai_serving_render, + online_renderer=state.online_renderer, request_logger=request_logger, chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, @@ -1074,7 +1074,7 @@ async def omni_init_app_state( ServingTokens( engine_client, state.openai_serving_models, - state.openai_serving_render, + state.online_renderer, request_logger=request_logger, return_tokens_as_token_ids=args.return_tokens_as_token_ids, enable_prompt_tokens_details=args.enable_prompt_tokens_details, diff --git a/vllm_omni/entrypoints/openai/serving_audio_generate.py b/vllm_omni/entrypoints/openai/serving_audio_generate.py index 81226439c..f8fedd33f 100644 --- a/vllm_omni/entrypoints/openai/serving_audio_generate.py +++ b/vllm_omni/entrypoints/openai/serving_audio_generate.py @@ -3,7 +3,7 @@ import asyncio import torch from fastapi import Request from fastapi.responses import Response -from vllm.entrypoints.openai.engine.serving import OpenAIServing +from vllm.entrypoints.generate.base.serving import GenerateBaseServing as OpenAIServing from vllm.logger import init_logger from vllm.utils import random_uuid diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py index 2878487fe..6896e3641 100755 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -41,6 +41,7 @@ except ImportError: soundfile = None +from vllm.entrypoints.generate.base.serving import clamp_prompt_logprobs from vllm.entrypoints.launcher import terminate_if_errored from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedToolChoiceParam, @@ -64,7 +65,6 @@ from vllm.entrypoints.openai.engine.protocol import ( ToolCall, UsageInfo, ) -from vllm.entrypoints.openai.engine.serving import clamp_prompt_logprobs from vllm.entrypoints.openai.parser.harmony_utils import ( get_streamable_parser_for_assistant, ) @@ -150,6 +150,21 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): # Harmony flag (always False for vllm-omni models) use_harmony: bool = False + @property + def tool_call_id_type(self) -> str: + """Return the tool call ID type, delegating to model config. + + Upstream vLLM removed the stored ``tool_call_id_type`` attribute + from ``OpenAIServingChat`` after the ParserManager refactor; the + field is now resolved on demand via ``get_tool_call_id_type``. + """ + try: + from vllm.entrypoints.chat_utils import get_tool_call_id_type + + return get_tool_call_id_type(self.model_config) + except Exception: + return "random" + def _should_stream_with_auto_tool_parsing(self, request: ChatCompletionRequest) -> bool: """Check if streamed tokens should go through the tool-call parser. @@ -368,7 +383,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): tool_dicts = [tool.model_dump() for tool in request.tools] if not self.use_harmony: - error_check_ret = self.openai_serving_render.validate_chat_template( + error_check_ret = self.online_renderer.validate_chat_template( request_chat_template=request.chat_template, chat_template_kwargs=request.chat_template_kwargs, trust_request_chat_template=self.trust_request_chat_template, @@ -396,7 +411,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): ) else: should_include_tools = tool_dicts is not None - conversation, engine_prompts = self.openai_serving_render._make_request_with_harmony( + conversation, engine_prompts = self.online_renderer._make_request_with_harmony( request, should_include_tools ) diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 2e9594c02..3db6b63a6 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -21,12 +21,12 @@ import torch from fastapi import HTTPException, Request, UploadFile from fastapi.responses import Response, StreamingResponse from transformers.utils.hub import cached_file +from vllm.entrypoints.generate.base.serving import GenerateBaseServing as OpenAIServing from vllm.entrypoints.launcher import terminate_if_errored from vllm.entrypoints.openai.engine.protocol import ( ErrorResponse, RequestResponseMetadata, ) -from vllm.entrypoints.openai.engine.serving import OpenAIServing from vllm.inputs import tokens_input from vllm.logger import init_logger from vllm.multimodal.media import MediaConnector diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py index cb84467a3..a39a66b91 100644 --- a/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py @@ -23,22 +23,7 @@ from dataclasses import dataclass import torch from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager from vllm.v1.kv_cache_interface import SlidingWindowSpec - -try: - from vllm.v1.kv_cache_spec_registry import register_kv_cache_spec -except ModuleNotFoundError: - - def register_kv_cache_spec(*, manager_class, uniform_type_base_spec=None): - """Compatibility shim for vLLM versions without kv_cache_spec_registry.""" - - def decorator(spec_class): - from vllm.v1.core.single_type_kv_cache_manager import spec_manager_map - - spec_manager_map[spec_class] = manager_class - return spec_class - - return decorator - +from vllm.v1.kv_cache_spec_registry import register_kv_cache_spec # ── Slot mapping ──────────────────────────────────────────────────────────── diff --git a/vllm_omni/patch.py b/vllm_omni/patch.py index 50eb4d991..4f2441294 100644 --- a/vllm_omni/patch.py +++ b/vllm_omni/patch.py @@ -417,3 +417,70 @@ def _patch_fp8_use_quack_fused_bias(): _patch_fp8_use_quack_fused_bias() + + +# ============================================================================= +# Patch CuMemAllocator._python_free_callback to fix CUDA double-free on shutdown +# ============================================================================= +# WHY: CuMemAllocator._python_free_callback guards the asleep-entry double-free +# skip with ``if data.is_asleep and current_platform.is_rocm():`` — only ROCm +# gets the safe empty-handle return. On CUDA, the callback falls through and +# returns the original handle, causing cuMemRelease on already-freed memory +# (CUDA_ERROR_INVALID_VALUE) during EngineCore subprocess atexit cleanup. +# +# This happens because ``sleep()`` calls ``unmap_and_release()`` on ALL +# platforms, then sets ``is_asleep = True``. When the atexit handler +# (``_shutdown_singleton`` -> ``release_pools()`` -> GC) triggers the free +# callback, the asleep entries must return an empty chunk list so the C +# extension skips ``cuMemRelease`` — exactly the same logic already used by +# the ROCm guard. +# +# The fix removes the ``current_platform.is_rocm()`` condition so the guard +# applies to CUDA (and any future platform that implements cumem). +# +# FRAGILITY: Relies on ``_python_free_callback`` being a regular method +# (not a slot or C extension). If CuMemAllocator is rewritten in C/Cython, +# this monkey-patch will silently become a no-op. +def _patch_cumem_free_callback_cuda() -> None: + try: + from vllm.device_allocator.cumem import CuMemAllocator + except ImportError: + _PATCH_LOGGER.debug("[cumem-cuda] CuMemAllocator not available; skipping patch") + return + + _original_free_callback = CuMemAllocator._python_free_callback + + if getattr(_original_free_callback, "_omni_cumem_cuda_patched", False): + return + + # The upstream bug: `_python_free_callback` only skips the double-free + # for ROCm (line ~206). We wrap the method to extend the guard to all + # platforms. + def _patched_free_callback(self, ptr: int) -> tuple: + data = self.pointer_to_data.pop(ptr) + if data.cpu_backup_tensor is not None: + data.cpu_backup_tensor = None + if data.is_asleep: + # sleep() already called unmap_and_release() on this allocation. + # Return an empty chunk list so the C extension skips + # cuMemRelease, avoiding a double-free. Same logic as the + # existing ROCm guard, but applied to all platforms. + device, size, d_mem, _ = data.handle + result = (device, size, d_mem, []) + _PATCH_LOGGER.debug( + "[cumem-cuda] Free callback: asleep entry %s -> empty handle", + ptr, + ) + return result + # Drain pending kernels before the C extension's cuMemUnmap. + torch.accelerator.synchronize(data.handle[0]) + return data.handle + + _patched_free_callback._omni_cumem_cuda_patched = True + CuMemAllocator._python_free_callback = _patched_free_callback + _PATCH_LOGGER.info( + "[cumem-cuda] CuMemAllocator._python_free_callback patched: asleep guard extended to all platforms." + ) + + +_patch_cumem_free_callback_cuda() diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index d84404508..6cd2b3566 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -159,6 +159,11 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): async_output_copy_stream = kwargs.pop("async_output_copy_stream") vocab_size = kwargs.pop("vocab_size") routed_experts = kwargs.pop("routed_experts", None) + # Upstream AsyncGPUModelRunnerOutput added check_ep_fault / _has_fault + # for EP all2all fault tolerance (PR #43637). Omni doesn't use this + # feature but must consume the kwarg to prevent TypeError from stray + # kwargs and initialize the attribute so super().get_output() works. + kwargs.pop("check_ep_fault", False) if kwargs: raise TypeError(f"Unexpected OmniAsyncGPUModelRunnerOutput kwargs: {sorted(kwargs)}") @@ -170,6 +175,7 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): self.vocab_size = vocab_size self._logprobs_tensors = logprobs_tensors self._routed_experts = routed_experts + self._has_fault: torch.Tensor | None = None default_stream = torch.cuda.current_stream() with torch.cuda.stream(async_output_copy_stream): @@ -220,6 +226,12 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): if background_exception is not None: raise background_exception self._build_model_runner_output_once() + # Upstream AsyncGPUModelRunnerOutput.get_output() accesses + # self._has_fault for EP all2all fault tolerance (PR #43637). + # Ensure the attribute exists even when __init__ was bypassed + # (e.g. unit tests using object.__new__). + if not hasattr(self, "_has_fault"): + self._has_fault = None with record_function_or_nullcontext("omni_async_output:get_output/finalize_async_sampled_tokens"): return super().get_output() From a28ded243ff5343e08e09d8aee8f6f2b3ce43a40 Mon Sep 17 00:00:00 2001 From: LHXuuu Date: Mon, 13 Jul 2026 06:45:28 +0800 Subject: [PATCH 068/101] [Perf][Ming-TTS] Add initial latent chunk for lower streaming TTFP (#5011) Signed-off-by: LHXuuu --- .../test_ming_tts_async_chunk.py | 102 ++++++++++++++++++ vllm_omni/deploy/ming_tts.yaml | 1 + vllm_omni/deploy/ming_tts_moe.yaml | 1 + .../models/ming_tts/config_ming_tts.py | 2 + .../models/ming_tts/constants.py | 1 + .../models/ming_tts/validation.py | 3 + .../stage_input_processors/ming_tts.py | 33 ++++-- 7 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 tests/model_executor/stage_input_processors/test_ming_tts_async_chunk.py diff --git a/tests/model_executor/stage_input_processors/test_ming_tts_async_chunk.py b/tests/model_executor/stage_input_processors/test_ming_tts_async_chunk.py new file mode 100644 index 000000000..bff3e31e3 --- /dev/null +++ b/tests/model_executor/stage_input_processors/test_ming_tts_async_chunk.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from collections import defaultdict +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.model_executor.models.ming_tts.constants import LATENT_DIM, PATCH_SIZE +from vllm_omni.model_executor.stage_input_processors.ming_tts import ( + MING_EMIT_PATCH_COUNT_KEY, + llm2audio_vae_async_chunk, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _make_transfer_manager(*, chunk_size: int = 5, initial_chunk_size: int = 2): + return SimpleNamespace( + code_prompt_token_ids=defaultdict(list), + put_req_chunk=defaultdict(int), + request_payload={}, + connector=SimpleNamespace( + config={ + "extra": { + "latent_chunk_size": chunk_size, + "initial_latent_chunk_size": initial_chunk_size, + "latent_left_context": 0, + } + } + ), + ) + + +def _make_request(req_id: str = "req", *, finished: bool = False): + return SimpleNamespace( + external_req_id=req_id, + is_finished=lambda: finished, + ) + + +def _make_output(value: float): + return { + "ming_has_patch": torch.tensor([1], dtype=torch.bool), + "ming_latent_patch": torch.full((1, PATCH_SIZE, LATENT_DIM), value), + } + + +def _append_patch(tm, req_id: str, idx: int, *, finished: bool = False): + return llm2audio_vae_async_chunk( + tm, + _make_output(float(idx)), + _make_request(req_id, finished=finished), + is_finished=finished, + ) + + +def test_ming_async_chunk_emits_small_initial_chunk_then_steady_chunks(): + tm = _make_transfer_manager(chunk_size=5, initial_chunk_size=2) + + assert _append_patch(tm, "req", 0) is None + + first = _append_patch(tm, "req", 1) + assert first is not None + assert first.latent.shape == (2, PATCH_SIZE, LATENT_DIM) + assert first.kv_metadata[MING_EMIT_PATCH_COUNT_KEY] == 2 + assert first.meta.finished.item() is False + + for idx in range(2, 6): + assert _append_patch(tm, "req", idx) is None + + second = _append_patch(tm, "req", 6) + assert second is not None + assert second.latent.shape == (5, PATCH_SIZE, LATENT_DIM) + assert second.kv_metadata[MING_EMIT_PATCH_COUNT_KEY] == 5 + + +def test_ming_async_chunk_flushes_short_final_chunk(): + tm = _make_transfer_manager(chunk_size=5, initial_chunk_size=2) + + payload = _append_patch(tm, "req", 0, finished=True) + + assert payload is not None + assert payload.latent.shape == (1, PATCH_SIZE, LATENT_DIM) + assert payload.kv_metadata[MING_EMIT_PATCH_COUNT_KEY] == 1 + assert payload.meta.finished.item() is True + + +def test_ming_async_chunk_flushes_leftover_after_initial_chunk(): + tm = _make_transfer_manager(chunk_size=5, initial_chunk_size=2) + + assert _append_patch(tm, "req", 0) is None + assert _append_patch(tm, "req", 1) is not None + assert _append_patch(tm, "req", 2) is None + + final = _append_patch(tm, "req", 3, finished=True) + + assert final is not None + assert final.latent.shape == (2, PATCH_SIZE, LATENT_DIM) + assert final.kv_metadata[MING_EMIT_PATCH_COUNT_KEY] == 2 + assert final.meta.finished.item() is True diff --git a/vllm_omni/deploy/ming_tts.yaml b/vllm_omni/deploy/ming_tts.yaml index 1a2dc99ec..684a47f98 100644 --- a/vllm_omni/deploy/ming_tts.yaml +++ b/vllm_omni/deploy/ming_tts.yaml @@ -15,6 +15,7 @@ connectors: name: SharedMemoryConnector extra: latent_chunk_size: 25 + initial_latent_chunk_size: 4 latent_left_context: 0 stages: diff --git a/vllm_omni/deploy/ming_tts_moe.yaml b/vllm_omni/deploy/ming_tts_moe.yaml index fe7806b1d..48c5e9aa0 100644 --- a/vllm_omni/deploy/ming_tts_moe.yaml +++ b/vllm_omni/deploy/ming_tts_moe.yaml @@ -23,6 +23,7 @@ connectors: name: SharedMemoryConnector extra: latent_chunk_size: 25 + initial_latent_chunk_size: 4 latent_left_context: 0 stages: 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 index a83989a03..fdac2a30e 100644 --- a/vllm_omni/model_executor/models/ming_tts/config_ming_tts.py +++ b/vllm_omni/model_executor/models/ming_tts/config_ming_tts.py @@ -22,6 +22,7 @@ from .constants import ( DEFAULT_SIGMA, DEFAULT_TEMPERATURE, HISTORY_PATCH_SIZE, + INITIAL_LATENT_CHUNK_SIZE, KEY_CFG, KEY_CHUNK_ID, KEY_DECODE_STEP, @@ -239,6 +240,7 @@ class MingTTSConfig: max_decode_steps: int = MAX_DECODE_STEPS latent_chunk_size: int = LATENT_CHUNK_SIZE + initial_latent_chunk_size: int = INITIAL_LATENT_CHUNK_SIZE latent_left_context: int = LATENT_LEFT_CONTEXT text_eos_token_id: int = TEXT_EOS_TOKEN_ID diff --git a/vllm_omni/model_executor/models/ming_tts/constants.py b/vllm_omni/model_executor/models/ming_tts/constants.py index a189e4285..e0508bda7 100644 --- a/vllm_omni/model_executor/models/ming_tts/constants.py +++ b/vllm_omni/model_executor/models/ming_tts/constants.py @@ -58,6 +58,7 @@ DEFAULT_TEMPERATURE = 0.0 # Connector / Stage-2 streaming defaults (runtime tuning) LATENT_CHUNK_SIZE = 25 +INITIAL_LATENT_CHUNK_SIZE = 4 LATENT_LEFT_CONTEXT = 0 MAX_DECODE_STEPS = 200 diff --git a/vllm_omni/model_executor/models/ming_tts/validation.py b/vllm_omni/model_executor/models/ming_tts/validation.py index c4ccf9dcd..3f3da6cf0 100644 --- a/vllm_omni/model_executor/models/ming_tts/validation.py +++ b/vllm_omni/model_executor/models/ming_tts/validation.py @@ -172,6 +172,9 @@ def validate_ming_tts_config(cfg: Any) -> None: if cfg.latent_chunk_size <= 0: raise ValueError(f"latent_chunk_size must be > 0, got {cfg.latent_chunk_size}.") + initial_latent_chunk_size = getattr(cfg, "initial_latent_chunk_size", 0) + if initial_latent_chunk_size < 0: + raise ValueError(f"initial_latent_chunk_size must be >= 0, got {initial_latent_chunk_size}.") if cfg.latent_left_context < 0: raise ValueError(f"latent_left_context must be >= 0, got {cfg.latent_left_context}.") if cfg.max_decode_steps <= 0: diff --git a/vllm_omni/model_executor/stage_input_processors/ming_tts.py b/vllm_omni/model_executor/stage_input_processors/ming_tts.py index 0f2effdcb..058cda788 100644 --- a/vllm_omni/model_executor/stage_input_processors/ming_tts.py +++ b/vllm_omni/model_executor/stage_input_processors/ming_tts.py @@ -12,6 +12,7 @@ from vllm.logger import init_logger from vllm_omni.data_entry_keys import CodesStruct, MetaStruct, OmniPayloadStruct from vllm_omni.inputs.data import OmniTokensPrompt from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( + INITIAL_LATENT_CHUNK_SIZE, KEY_CHUNK_ID, KEY_REQUEST_ID, LATENT_CHUNK_SIZE, @@ -116,7 +117,7 @@ def _decode_stop_reason(value: Any) -> str | None: return MING_STOP_REASON_BY_CODE.get(int(value)) -def _get_async_chunk_config(transfer_manager: Any) -> tuple[int, int]: +def _get_async_chunk_config(transfer_manager: Any) -> tuple[int, int, int]: connector = getattr(transfer_manager, "connector", None) raw_cfg = getattr(connector, "config", {}) or {} cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, Mapping) else {} @@ -131,9 +132,19 @@ def _get_async_chunk_config(transfer_manager: Any) -> tuple[int, int]: LATENT_LEFT_CONTEXT, ) chunk_size = int(cfg.get("latent_chunk_size", LATENT_CHUNK_SIZE)) + initial_chunk_size = int(cfg.get("initial_latent_chunk_size", INITIAL_LATENT_CHUNK_SIZE)) left_context = int(cfg.get("latent_left_context", LATENT_LEFT_CONTEXT)) if chunk_size <= 0: raise ValueError(f"Invalid Ming latent_chunk_size={chunk_size}") + if initial_chunk_size < 0: + raise ValueError(f"Invalid Ming initial_latent_chunk_size={initial_chunk_size}") + if initial_chunk_size > chunk_size: + logger.warning( + "Ming initial_latent_chunk_size=%d > latent_chunk_size=%d, clamping to latent_chunk_size.", + initial_chunk_size, + chunk_size, + ) + initial_chunk_size = chunk_size # Stage-2 VAE caches past_key_values and stream_state by request_id. # Replaying left-context latents would double-feed cached decoder state. if left_context != 0: @@ -142,7 +153,7 @@ def _get_async_chunk_config(transfer_manager: Any) -> tuple[int, int]: "Ming boundary continuity is handled by per-request decoder state cache, not " f"latent replay. Got latent_left_context={left_context}." ) - return chunk_size, left_context + return chunk_size, initial_chunk_size, left_context def _build_chunk_observability( @@ -196,7 +207,7 @@ def llm2audio_vae_async_chunk( if patch is not None: transfer_manager.code_prompt_token_ids[request_id].append(patch) - chunk_size, _ = _get_async_chunk_config(transfer_manager) + chunk_size, initial_chunk_size, _ = _get_async_chunk_config(transfer_manager) patches = transfer_manager.code_prompt_token_ids[request_id] seen_patch_len = int(state.get("seen_patch_len", 0)) @@ -225,11 +236,17 @@ def llm2audio_vae_async_chunk( ) return None - chunk_length = length % chunk_size - if chunk_length != 0 and not finished: - return None - - emit_count = chunk_length if chunk_length != 0 else chunk_size + use_first_chunk = 0 < initial_chunk_size < chunk_size and seen_patch_len == 0 + if finished: + emit_count = length + elif use_first_chunk: + if length < initial_chunk_size: + return None + emit_count = initial_chunk_size + else: + if length < chunk_size: + return None + emit_count = chunk_size emit_patches = list(new_patches[:emit_count]) state["seen_patch_len"] = seen_patch_len + len(emit_patches) latent_patches = torch.stack(emit_patches, dim=0) From a6da4691347b6c044ed388043a5febefca7e5c4e Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Mon, 13 Jul 2026 08:26:37 +0800 Subject: [PATCH 069/101] docs: update README for v0.24.0 (#5060) Signed-off-by: hsliuustc0106 --- README.md | 8 +++++--- docs/README.md | 10 ++++++---- docs/design/architecture_overview.md | 2 +- .../omni-modality-model-architecture.png | Bin 54056 -> 1132831 bytes 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e300b6b8d..e7fc6912e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Easy, fast, and cheap omni-modality model serving for everyone --- *Latest News* 🔥 +- [2026/07] We released [0.24.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.24.0) - aligned with the vLLM 0.24 release line, expanding production-ready coverage across TTS, speech, diffusion, image/video generation, and robot-policy serving, with major Omni stage runtime refactoring, diffusion request-level batching, async output materialization, quantization/cache/memory improvements, and broad CUDA/ROCm/XPU/NPU support. - [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. @@ -31,9 +32,9 @@ Easy, fast, and cheap omni-modality model serving for everyone [vLLM](https://github.com/vllm-project/vllm) was originally designed to support large language models for text-based autoregressive generation tasks. vLLM-Omni is a framework that extends its support for omni-modality model inference and serving: -- **Omni-modality**: Text, image, video, and audio data processing +- **Omni-modality**: Text, image, audio, video, and action data processing - **Non-autoregressive Architectures**: extend the AR support of vLLM to Diffusion Transformers (DiT) and other parallel generation models -- **Heterogeneous outputs**: from traditional text generation to multimodal outputs +- **Heterogeneous outputs**: from traditional text generation to multimodal and action outputs

@@ -57,9 +58,10 @@ 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. Qwen3-Omni, Cosmos, HunyuanImage, BAGEL) +- **Omni-modality models** (e.g. Qwen3-Omni, Cosmos3, 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) +- **Robot-policy and action models** (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy) ## Getting Started diff --git a/docs/README.md b/docs/README.md index 66fc8ef46..f6263483b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,9 +28,9 @@ Easy, fast, and cheap omni-modality model serving for everyone [vLLM](https://github.com/vllm-project/vllm) was originally designed to support large language models for text-based autoregressive generation tasks. vLLM-Omni is a framework that extends its support for omni-modality model inference and serving: -- **Omni-modality**: Text, image, video, and audio data processing +- **Omni-modality**: Text, image, audio, video, and action data processing - **Non-autoregressive Architectures**: extend the AR support of vLLM to Diffusion Transformers (DiT) and other parallel generation models -- **Heterogeneous outputs**: from traditional text generation to multimodal outputs +- **Heterogeneous outputs**: from traditional text generation to multimodal and action outputs

@@ -55,8 +55,10 @@ 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. Qwen2.5-Omni, Qwen3-Omni) -- Multi-modality generation models (e.g. Qwen-Image) +- Omni-modality models (e.g. Qwen3-Omni, Cosmos3, 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) +- Robot-policy and action models (e.g. GR00T-N1.7, DreamZero-DROID, InternVLA-A1, Cosmos3 action policy) For more information, checkout the following: diff --git a/docs/design/architecture_overview.md b/docs/design/architecture_overview.md index 1c38ba671..20c99fd9c 100644 --- a/docs/design/architecture_overview.md +++ b/docs/design/architecture_overview.md @@ -13,7 +13,7 @@ This document outlines the architectural design for vLLM-Omni. The primary goal of the vLLM-Omni project is to build the fastest and easiest-to-use open-source Omni-Modality model inference & serving engine. vLLM-Omni extends the original vLLM, which was created to support large language models for text-based autoregressive (AR) generation tasks. vLLM-Omni is designed to support: -* **Non-textual Output:** Enables the integration, efficient processing and output of various data types, including but not limited to, images, audio, and video, alongside text. +* **Non-textual Output:** Enables the integration, efficient processing and output of various data types, including but not limited to, images, audio, video, and action trajectories, alongside text. * **Non-Autoregressive Structure:** Support model structure beyond autoregressive, especially Diffusion Transformer (DiT), which is widely used in visual and audio generation. * **Integration with vLLM Core:** Maintain compatibility and leverage existing vLLM key modules and optimizations where applicable. * **Extensibility:** Design a modular and flexible architecture that can easily accommodate new modalities, model architectures, and output formats. diff --git a/docs/source/architecture/omni-modality-model-architecture.png b/docs/source/architecture/omni-modality-model-architecture.png index 53978b32366ac9c701053c7d41b5192c600b7c31..7bbd5b9b3ec99b9a15165e0467ce6353feae56f6 100644 GIT binary patch literal 1132831 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 arX!o?>b&?`^R)fWTHj<*m6p{P~{y-~GPRUh}F$ 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 _!)j99)wlieQjl&;$sx>9?4q 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

m2Pl0Zzf5kGWTKrAj} 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�);v`J0cr(WAv6@H$EceylTz%xfU$wq( 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�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 literal 54056 zcmZ6zby$?!_dZMsgVX@h(k0#9AzcE}-JMd>-Q7cnB1*S_v~+`Xr$~eJ0Pn-`cs}3X zd-2Cy*F5{#d+k+w?fYIsq>7R>+H=C^FfcG^vNDpYFfec&${x8-=WTz&ICZaLTW)v+9#ay<6FJT9vp^?P=?FY;OxQw8A# z?EgOcF$3WWAOHDA#tY#cOjH0xKIj!(l&Br1%H&I3pL+NIja5Mqmk@QO=7N)hB)@-O z=lXZ-pH~I~0+M#jGM*hu(39o+s{gl2pid0&z(??g|J|Rd0-<36ahiY}6t6{PU5opz z`Tp+`F9Lmnf$>xjo5QF-qWqP}g(AP0k8hWH`#qQ2FZIY!GjnrUjhDsdLupA0s^vOd zZ{DD)zC-J=Kcty*uScSTER(8@K~z@_K@~gD>~=(|mgTf*_jOCG)#_c5zG1PqZqxQbw~Wn0VcqOJ zCNFPs@r{wfnIfu)g9B(O`v6U371aRg^ONG9q?IIqy<_cK86+Xv{#DDcPGVTr|&~C8Hq!PEg`}KZ@{D-rV;%nUveZ$XVftMl|e)5q` zS2kH*i{}TvvaOGErm4K%oifZ`q3th-0n>iDn-z>b=l(Csnem zth*X|#>As@|D);d(y8 zLy8*E`;VkxYCxCL$THmHYCY{Gs`0)$ilCow@u;M0^t*q*`D+^n&L~r7{AlSbW@P~tQJg#N2zVUn_IHK~^~fGuJbx4dWtIPR2M4wA+jQA$gW>RB3iS6-Y?4t=3Z?Q)>5fcpP&b$p{Dtg#CGQ z?H3y|52)qSIGJ5*z0NH*ui9JhFBi|I)szP=4;Sx~C)u+#eeSCjH4>iVG21owc7HkD zazVb-fScs^xpunQE6H;yCFdT9bP(Y#n$(%cdl5P<4cOz?SV0^aEJ&1Uq289Zvh^%V zAy-hl!D0E={`dCB2L_hblOR36o9(l^Z{;0Y4jWP^Mjcc&i)An) z5on`DiU}V1cC#r%rd$-4Zvh%%Go%NV(|<@n%kiH@`A-+=^ax__z&-+uhPG! zOHGAIxI*FdX3Dg&$+-RA0CM?J{z!WfI4$-h)o|)1XC(ONTy;?l5zpvs5wN@=;4Bt0 z>?OQafh;<8G7e4ahy(lkCWe$>J#iVej9e5lSjB+9z!yzTT)`jY)8jt%wYVR$Z_B09 zQzsac=tVSb#tE2xkHYc9NCHwhfh&Yg?PCT82#;CMQziFZL_b5DgIzK~yaa~O^t<>* z)ZXOo?O8*Au2LVt+hvbcg7(MlMD6p*@9Q zSn_9yC?+_&8q>ZwuGo{o!;4u%Jel9?;iSy3qj6JrMzT

Rq-6YaEvO#VCmfr5xyo zyPs{W)R@bwbvw4-TP&$JiMs4r-kfcJZ!@8N8K-3u&Rqv=f!%7HjPKW{qG6(d_J><} z)*twMF2#|Jkg5I3_LYpO6C-#BJVPI69TM#{O`qktFb>+&(V<{^`!JJe=z?vfYG=Lf9wTsG9Gs;-&Z15GHYe_}=5M z<*!%+ZIOp!jF9+4oz=SCX7y=FeNF;J8x%tJUFM9#38v5r$5mc0He@T#tkPgl&VSKl z(XKIzY`t3X<|M{j4-AMOVbzW-7Q5sBX7KHO;tL*gMz#=6?1?&SwMsRc$-X*Z)@X6JUypr8bsNJm>O@N*V%mY8 z$)f-rb+yTBBm$I}+_>^yerGt-bkKD`{w~D>MaOxe+Ih1tK7EL}Ww_PZSbu8PGS^Eg z!95>30oEJ`a?nd9KQENoHw+L{_4Le|?3r0{7GlOB-n^zP0yt>MR)f(5@LH|(0g zai(#6Qllb|K7kQce5PZ>2b2Q0qYTc%Jw#kKDQq9AAge%PNwKL##-M{bqm8Zs+6Wdr z?ZIYu-5Iv?0?i?TtTz6uSe zu8YGE`kf%aZ{}aKHq>ldp(UU2DV)?#%hM@P{VNhSs;)<_s2_+>xJqXEW+n@ zuVlW4y}b@eELX_8Vd9}i*fZek-BHVTNZLuhxR+D-T#yjNq;Y3Syp_d#r0q zgF^m=^AJ-bNXKf1Za)CS?@%JMd~;0XF(sP0Sq7i-z&J@PWKU(Xxw>uqPBTdGQQ&^bQKe##WE3`Bj$;a&m7=R}2-V2hn>9`>yw z|AMk-XP;le-4Fz>3HbR*z3!_3A&7NgadSJ~@$<@P7{dprB>HtB)2@LGw-@=x-;;PO zIq$@~B*^>8tEn1tSI#Q#<$9m=6^jw)??3he17EXM8xQqWq`D;p=)R)S~4r#priv%4XC5wYL{6sD*9A&bs_4d~n zIERE+aXzN-?K==#yBa><9y}+(gtL!vy&0!2y{uc$*rTPu-p;hq$08Q22#hx{-Vu14 zv?SsZdE{b%_tCr&&tsD1#;l0W;dyKXfiZezG=@c-T$5#XvfFwI4Oiq?HGjA3Haa9_ zW=3j`e<~E>g5BMSFUL;Z_a}iIHSaSQts{8PAY;(ho&L}?yct})*$}Jty}P8z4aeI- zH_Y81Y!N%*YRz z(IPmD6Y=Ym>IM)a-O-tdcc{U^fV*WghEats(#kh{3g8BgLhp0qtq^4FVjzw0#4Ct; zPDCVzYxW3vU-mHU=6K|Ksd!FKOzR5DR+%A*Y*)0N$rT}oCFH*e28}Dryv!23Nf52< z_BtG8@KZqVhYw3ImrDL=W6sLm7bf3Xy8l%s5Xw(q!73CuTN8^H#l02;Z4Ci4X zIkiYnCQ@2V3~H^$D`jnxOR`n4?z1t??DtDgFB^d|Qjp!%j)xlN! zeDtZ-)Xq=$F}?MfUR9vxXEp6`0P|#=ETo|&{YE13gz2MAfHw$T1cAE0U9H#(>zHCw zlW-vFnE~0CH!K1xJ`D{HW`exaw>V>&abhV^Y7tM*Deznsznv7MNIe9^=M@!PXAM9qbe((eWafFqExq*!lusYAwj4 zUYP1P$-?)k zZ;8qDjg!zG)kReic>GzDVqEiYq5p7(N0UL)-<0qcqaGFzj0uSY8 z2WfYwUnXY+rMXVM4YiTILB?RWtrF0CEH z=Wcrzf$|nVd?afsyAR7y9+7~>y+<(Oq@#xj#cr2;sv$nGs@}R&FPdCOeFrND^($37 zwf!81F1z(=oSB*D4B}0hC_P&iSq$RKDY#q3jPP0}-G+=!1Z2mhCd@t%Mt*{S1YJ9w z0=bNnKLd<4O`P9`PPHvkq2s33ZkhcZtJPhASHESRuLX&o*V;DPwAXHqWi-Ln?uxC+6X!v?k_?v&l>8hClrUVJax`Qsw+YGGK+H=#&eQ}OqIldbjm*foJfaUoN*B&eT=%1J`DMg@*Jchxv(K$s!mMYS*1 zTbs0J5@ZlgYm`H5N-4Mvh-v#T(AMZC_;&il-& z)M#R~%E`@;%SoLdv_uOd!UF<{FFy_w`8M1`S05jwam7sIiTSq1MyY#xsKUHtaedgn ztp$c173GBZXjB?3TTw)Al5eLQ`my<^(1IJb24p>l#Q8}p&W!q?;&Tgi)|5V|Hj51( zaoqh_jSI5d=a6vw$#;laF+XXPLv9kzx002){ckY4Vzk4Uq7|E{qkEj)`I&7I@d8ga zf{-e3wDJj~j$pi2C-Tk&a|O508s+o^1SA_6>pR8}!7{uVbo`TspjDXr z67(4&8r;@BG8QbkJHZeYS`5M6JpVR6;W%$t3@^9+nGb}4lAF%h=1vr&&rKW86Bl)= z%%|V}DF@Ea5BBFM#bI2&hs86IgdS9PM%%s+a z*yrjxetRoZOZ2jGoUNpady<=!xgn)#=3!!{J50gn6_#us?{+Z8(zvIXd)0o35mwlA z;nxuSxy$mjj4CdnK+zLtG6Xz9GT6(?GfYRWm=su35ZhDI9w=y`@4NOT4kIm(ccu9J6gAF@L=4-X~N+M(A#UXZI z9VYr~iaqQL7YC*ii}BnagP2nC(G3}b8f1vj4_pUd*g{21`M&*TWZzBqsys4c;4aW%oOlH-ijq7`c&yHU2D*CL#eO~bCAjT4>70@cM)*~klo`&k@uGD!W10LTUofOc5(9^A&c-R~20vU=h1X>0P! z8=RY3I}4ad^pJ~wm@k{SOX3IGnY0WewQhCC5gl^tSStj1HWi|Sm~P^EH$Vk~SID=c z_&-IAInRz(8FI#g;}uvNW%FH25T)h;!#gSNF(uUyxM6k#_S_^Z*`3TwjV42c9< z-@X;dc-?1OW1^_kzlm#SyeHt?Iw^IxJDLTQhckYef)49-AO#NI3XC8mSm%-)t#2sA;qs*Cx0fhIJuJ;xL%Qc-O38Y$_5nd`mLB$T7vAJUU8`7qWc4 zHnXq%BIs^phZH|yD8O%T#!5FNVkblHfceJ+WohTTZ)>0s_EC(?AE?IKO9I4l0nX}BDB1z{^qdMX(k zQ1}2J%K`&HFUVjacycLgvVQ0UZYWPcryjzRq;{(IdSSZY4RD+rGuJ(T8m5fEs=qcT zU?&Futx$RF&HkrX00joq6PEk~@dv|@4)Mj^L`O}$KUDISrC(qI-i?5V`)nNt z-_K?vF6$Z-@WTcNSmpw=swC+IQ~OuEl=L57#ZQ#X-Bf+@rGQ;>p@nauVwCl`Qt>Oe z?5Uv%4g$M92oup!COH<$m+pNnmx@<0#~YmEuG~2PRpTh@54er75(^}v|~kHdhss-0ep!1iHi1RC_kZJ@iZ>Z+Pj4d`$G;>9wr;F=$4UOar_ zswn2OQK4~see=sr)?l*Mb`$06-fB{+1M#PBj22WU^C#LrhVY zE-L0DRrG21yFZ(-a{KfjO6@|Hze6ynzU1esqhUBAUoJ|W=P8e#aR9uwFGuL z{)R@#8KK*erok?wGd;!`o>&!|W9^9%nm z-V;iW8(3DxXb7+2wH>XZJoseW?T?IS=!>L`l~GF~Tm81vNl~IoAS=i}p#KdX?4`~R z_L5mBgSArS&9F146@DHV@%x2ECs54o4#j)1?0X zWP%NBe1|JQwD0$u^;?;wJO~idmQ<&G^t9EPk|Xsus4w94;hPaBtP2uEr%pcs^)_RMlHYdwTI}Z8*ZX2rkVTw8oFeWFOE87x4Q=>M zg>DV%s5&llr~0L{SU z3ET;Hpx%h8&;P@5h~k5+X4GGI9Hq~MzN#(Rz)JtTIq8S=w7iktnLgtd!FsquD5>A) z$U|?xcV4#nlLJ?X-%UDuKd2h2A_to_!IoedME?46ANzeM_J^+!%U!mAUPiQ;R{KKu z92uZ&JUuSHdhl9Us$pn2NvguY@bECrLlz_~JoA_^i6Fc+A;T(F(ZVO`gJY&&d%ae; zshgv6xxSKjmJC2O&|>-8Kl#Lqz-zMn@_!wE>nXc>A*e+E8#@1f-2)%k(@;>%SAA`} z|JU*U(1~4Exgy$eDQXp9W`1rf0ZSs#*#3}xb6%~XoG0}5+II$-meL<-B z{}QMw2!envFW2&C)kQ_`{&j>up&kSVEKjypul?`T(8z!|9bi_~r_5B!=eS_|=fU5l zd7s!VqB+9<{{rZMNavjyRx$sWr!+R;(%_G5WWRu=rODbsKUaGmy2 zbt3=owq?VCZ5L-@9VYz$whavh9zeNP`E~v$Qx)jb7f{Gk-N4#0S@!=<1k?n;apt0m)fqaL69}93&f3|uS7ci4x2o88B zZUjUz-H*?2hiH1pA5KFe^aefqfnI$!tx8`quyLa?2Kh z%nZyAV%(Z1Mcn{DdZLAg!{CR}KX7sIfPHr&Z_bo4sIx0&U0=aill&F$$-qJ6FtEW1 z&*Ul%+uO*VfMc0rwGyQy&4PQgbd^4Q`mmuiCTxc3!(1X6p7YTCIE?Rkm9iWG)T-i{ zWFtjSVJ^xHvU*}YsX&3EAp}9szXsQ>Q}n#104Ug`OzHpTIocLo9u5`plG;Q?dbc|= zl4m)o5bR(Kt6d;*3Z6bplo7m5QzI()%~x`UYpJ(G);KWpFF*dIqX=R+Fb9af?vwZ6 zUo2pY*}c5jcBGmZZ!7c0xiP4Bo_KVGl+8M<_!-5!cOB2YHpsuCv8PVCc8zVs4C!fM zp+`!odMzz@{M|lTCZ>_U#bdp12&2TXo!0V}M8J)+$T(~G`fql-NmPR1Y_UlwQ4u9b zL71hhD_T;ey-$~7&ysVn>GUGc$*%`=-s5vqVylG-!h;>D{5Rtf!}1`RD2F|ojTID_ zNd|m%xf&9RKe`rusFLA8KxN-C#3T+V(_sfCk40CVP+&g#eW!dWD^m2PH@5P7uGa}E z$NBfxn?7LDf1SSH5+v!?r1nvP?V|dshBhm}Ac+KasMAh*U3Mlfi-_Gi%=1Q5p&M+j z&-k_T<=wlTAg=(?b9nd0{#G{sBVM4;`s=5nTOchoYH!D_daD6Rvb7yqcM^#`>HcDZ zjqQpAMDpPTE|1n*?06Uvqn+Mm>>ZZu&~;`ayms#YPKja=oCfBHm#<#a3|D3)j_S%3 z&oiTYWPv$6wC)E&VNl@8IVuhbdggC`y#3?Y5yEgfrVrxz00Mnu+hNVA!z1$CO^ZCy z;&E`C{jSfNmk)FNtK6v9gaO_Eq}%{Ugph_xLY4{T2xS$STsLTF5}D4azGqJ$4Re5@ ze`#}IS6~9TG`#9QDUIMXxz`Bd6eCB0dvMN0iG+S6F7H-UO)KisqeLq}R)~PXWe5@8 zIVwU*K`oSt!#ThyY^-z$ce%$~FJ16W^KTep#Rs%~i}gWIDmD9bj*n~IP`WTguSH5kJ@T|sY-$#DtWt@z$w=3lTe7J)aC}ZbwHdC1PUrNA=5Jk;W zni0AHE|qC)roH_DwMg1Mv1@R#=-AGrEOe1#x7sFjHB6U6$vBxUzByU36Che6M6f&l zR#EluhF%1+Vrtb|C}vQbB>PT%RID!L6|1MWRsx^gv}zN#{Y@VUH6W^%v%Sjphop=B zS%4;nQiwcU7Y%3gsc}25y!2?jp|q7xXHEr(NKgioUK8t)FX)5VEv{T!3eAX33-;g( z+kbOPAaI=3)58q}!W82t$2f2HW}?TEiWmSqDy61@*M~ZrA96Z%R^;$V=$zKo&7y>FZH(f;t>da0>;$*CJ{5?~s?UT;L311X7y35SJF5%R;G7AHOXQu}XY zs2~)Eb^tQl$eaSKK4XHp$2VzWw-iymJfAaJ3B6_O>#Ku#N<+V!55JFBSFM7sj+W(r zi=D0dKN7##jmyvdKXhK1z`!-YLzzvI9et2XWp^(1l(n9;gT*jwF`e4j`xteo^r@13 zBQmGq|9HSUrOGFjg5c*lE`xNvoTih-vR$&c_6v2103mNVbOo4g@HL)3)zy+~Jix^E zSp~6t-NFilY2kOTErK0XQ+z`!>E{b&xoK0N~%Tu3Xx zb~U9i>CRlk>!-08GSBLJV()jVpKo2=}UBozeePyH2t>k2fW zfGlgO$FY$B?-s=i>y+(v52iA>GTs6&_cy!k$7uHfd@fp`l&L}iz)}$P(auR~R_M)# zI5zLmh@JcFeMledBlTze9*xH%dGiFp`t`e1n|${P$%)7O4wa9WA565UUzUvzEh+l1 z98g7r1-Sy%c2dLQjHJo#XpYz&ujS}V*E9|S-l>~FBofw-1U?`GMcGBvaFX|*PMhxnZ~A3vbEqC5cFAn7nT->7 z%&r4sG{H74$rNAYTT1`aFH&GeJ=|3w>t=6R;;XlM9~B7HupvhM?14AuyF*(8$>u=A z0D^B{r|}hZ-ZBpvVma!v!?=AuDloR?Y&g!`=(xHvZM+$2yVy_;^p8wSnY#|9GFWyw za3qpTgk>~b^z*?hLYcvq^~;f@EB|XEN+87ycvbS;*uqaPjWN>H>|m~X2P5mGnglaD zCD69M`?&3{LY76pxlX-Y=Pf%sy9_tPsEwF1;0BTOx!=VP%^)wt9el@c7iraoJ4K?S zMmrgNXU*UJLaq-RSDzRAb_Bq75ll|QhP&=6Qu}K`cFc$}7LFvjDc2WHBxj<&q`d#H z41FO%#=tN%cl%Mb5#_}zvL`NOkG-MKrw7TpT(dG)4`>mfY4bYYnXEFF*rd&&+~vI~ zln6&xZFJD*`v))z61o4KyaXVuJHcZ8MudokNdp=_oChBjq0RcTcxj>GFA2=TZs)j0h@sakwAp?@z#17JLKY zvZb;5a7g5%dj#}er9a1^pEH_cF0m4A>t@aVk(O{{T89KN=Hdxn;zm4 zzyAut9LxYlU6UAON}!XF?T#TmLc*@OJ+Dg6hGH#uDq9MSYmHt%nN1k;T1P5u;V2}U z!8I~OSLwWjA8x__-$_y=!4`VsqKR8H!&Z6r5XDCCk3veZp1@_bW3)PFaY+gr*Lt*^ zT~Qeg;b3EpX8owzcfq}{)bKy8@*|v$lSd?Fu}Y+|^*L$owUZ_(@p+J3?20_K=(Y1* z#m*&J5_O`%RvWwh7ai}!VbIt^GTEw;#50_cb;;u|HVchhj$^@A8*e9(V6UJ%oS&;? zY9G4>OHCBz(GZ}!(~o=nL2s-u|J(Z>H~@7~2%X|(1nZP_7Lo{@EPASXq9L^}CMcS- zga!4+!)%Fnsc4oc80f+Ss!Gv^*P_z7N}y|OiwLLu)5J02<96xW@MW(bc{-r$F}GLW zFrqmb3Z6fkoE{ARNZ@>>vpXLfcKff$0HfM6CidQyV%UMHR=}|;nNyWG9lGRyrMGyI zNJL_sXyF0>_3g`{cjW>;2#lEMb*xQnO}5is?nMPp=91w-Xd?XxDPDZp<1KjL3*1qX zjY2XXuCYx$3}#Tg>|S1AHk@9+RYihrOq}l5thSvDR^+H1eMdj2o;L@e_c{ znhkp@RRc4~@>6fgJHGWX+1WjGN$Y);t+)9>MLSUL!~SuXlO!umxBb3)m}yw=TJg;S zVrQ3-0Z*b!pDxUS!Dl&|XqjJ4!}Z=PP(BqUO5|wHTBn3;`sunY$&N?lh4%D^XsO+K zQgXB>jd6n4R)VUW#m*SlyQvRwxw-ud>>RLS{XUuye_h{*H+L}J=^8~je|=vXA^*x3 z5yeCJ%Nv@}xW#YEIwIDfo3qN|(SGf|qpv&b{iEiuiuzMI?^qB$Oba;ky^`Qz1>!q# zkx=@U)p9I(2%jP$u$8Ri9tmy-_lT|MZq|E=fHor4;!2NhU1lHM-pBmw?1w_uFe5B< z-1V(zj*mc_OD643^i8m&gdo@>SN-xX{=2_OogD2KeT%X(COrf@r?*LXursXtv;GxJ z7}5p5g*7J+4Di)ux6(FL>fF?nBxWvaFOBxcSu<;s8G^b zP42qy5nbj^4=c{jmUw){=j3X=Ap*sG@A$sxQ&yWh`W2O5M=PT22n!XLY`qQoaY%Z+ z%0py<+DWA+C_bXR3f;}myb7cS7le6|9WX`YOsTsOL>~%-@TpV0HW;j~I3%PkOA-Xl zeLc-1Jra4zhwq^5N9LMm{)YBuaR13@K z-Y7t`=#urD%f2%HM?WNr;j%qZVzSC@>8-$wH>Bcz8$rlkLC6;&^nxig$sr4lXU~ZI ztFDc-5Ti=`oI0sFcL8Gayl%;Ouvdbo@MEPV!{q`w zSp@=Rf09%X(D>Vd7`oPY(^Od{R?sPQJ}OmHsbMUkXW!gh8Ys%a5-CNAmgumPV72Ia z`Ml@b>Rg?f`yR`EgYP?6VxnDl?V%%9znO-Xmlxz|Rj1w{sx4$1sGm5_ZGr-pP5S_z z9kg=;ejT4N?)Ki<_#vZmsVTvq&x4n0QbFQ{i${=H^%|f^C4>ypoye81{?j4BkvX=L znT`nlDia7mT=rjqoGOjaT~=9|((~Vj8I|*SMNh01T8~^AeSn40z(xzaq#Y{@RVOH{ zf7@_B601=C(cYj@o-eM}H19R^921@9=>0S)A6(#xIWstd{XAH{tzQ=tIg)blq&R zLu-_O&cySUJCaC|x4VjWIZL3bw&g42Bc3$Ik|YhjuWyuAR=79_e=_(In=y5uG)do3 zB#(DDyIoEAAG>;Fa`q78^^XZhcchb9Yk-ijvuJ5~w6r}0?@=_b%W34q_r7@hIQWy5 zE$z*W>5;{-jr+XD!eh7o_y$py@+eel?m0%bX`3AzJ9a}?dZcmRDkz#mpz$g=dBze7NgM2 zDYU```xdW4%Ow-UlhTGr*0-AK%11u(J#&-jlDKv>yn0{u#X-EI zFKCnZ{o{idsnj0pSO%g+wTd&1tD4A6KlLqdMRBI~kipP3bY-)OfSeP5_qXe@uUyZS z&DSx_Q^w4l>cH_GTx31dzC5Q4LiHX&mKOWOKq$TnrJj5r96K^2Es(R2!=Ta8zY&V! zH_QcB@j!&)rG6G5N{C}_UuAHRko&%|#0e{Gfl*iTtTOr=hmNJ*49J#xeteC@@~$V> z`u-O^+*fiix3#+_C@@TL_cfVEA_zqY0{157qvT}ck`pMbw&kU37w^*fi^x@dB%U^z zUlHyEx9iF&%l4vh-j4QR4bOOga8R#QLF z&;k`$62nA;&1u6WTjXo@=)Bx1llL}rm%?}9YB53fOkX`8yg)yq{s}`4qGaqd!6!XF|)q~ zB`p&@GECItxoB=?bZcYM9N}Cm=A!{PoK=cJkTHb5A;wL+MdK6RsUydat|KQ0PAVOD+3xf)zmkzEI0FYmz62#&;(BmRrSy*qH>`jtvbw5g> zB&rp@l;6Ub&LId8T=eP`O5nKh{hfZXh3n65H`Q+obdJrl&zK+TRJ*k-QjI3r=naNW z8Bmw8N=4!<0-tY6nu;pkHvx zlG&6U>nipG(6Py8-K|eYRC~80MQEm2KSY+uTWvu!9C{e~C^&y@=1iUoexvs&{Nt0p zVM2p%81kg+0sH2L<79L4HOeU;@^&}QBI3Q%6^7m(Px~_upThg!Lze}a5Ob|p(`c}; zGbk2>P~1dtlq6U5qoeEyd*O4(w$BQ(u5FN`6Qm*RuT0-Z(5u(2T9)RPh&V2a)`#t8 zL6ve?WF30>7bOg3&-#fU%#uZYgR{kb$Q$jw1b27f_@s zpBGq5nF#rCn_tDEnIIZVbrbA2)IXenS#=)Y|4<T?-tht4z*5)PA`+cq+#>g?v(ftgyZ>^c6EY6pCEt2sg-((BFg+|bD zXrqVVAwb%adygq6dNPH_(=9%mBO<1PFst@y4X#j+o*gx86u?A*37OI;o(QJINI5f} zWgbFqWh%&hAh>CNf9rm2n7HI&OCmpc$S!PzPW!!8ePM!9zh%&E>aN9}fR3eZ;^T2o zZ{LF3<4RU(8sCgliQB!#;(>wMDbOQAdS!rH>ApFE?kT0aF>$PJCn4)fKdJ+Fd(bxD zl(%%gfa72D%y{%&Vs-0G<+f=!%twxliE%jFpe*hO$>;k6m++IB{cVz8?EBlxqffQ{ zTlJzXTNkF>C-GoqIJ!Wl-zu)loeT?^sB$d`V$jyb()|#yCWDYmeuhwX{u+>n23~%V zt-JTyOJ^biO^G@mL5GoT$yo}E`B;8#7u5)U=re7S%h(Kb`MuDg&^@!Wl5eM1sK+y* zpiWx&<7lFO|L${*&O1ZNmqH`coRo1^CH`s?WUPDzTWzG@Z6AZ`#8{c$n8y63Oh~|0 za3R#>FMdYk!Fw9WiuS^CpDNbN*e|0*#u?yJ=v;2fUP3HFj2B-=hyYlxRKKqV1`-_3@YtIXZ?^Uk9p$JtF0dP+hv2OWw7RIAxZ|4AdrBfYp(aVXvn6qnV4v z$t{9B%|B~PMOaa5a_@}Te@sOECXY9T8oeD_E}&g0n(Z6N`yNs%qlNDgAHXa0G9dLtHIL?`#1bD|M;Zn|ggoV{1C<$Yo^G2y=Gq#KP*(!hJh=OH28IJkPiQS!%{Cf1 z0@wNS7;j+^X2e=Oip)rP(#RlD^4(&CZjr_Tm;5P-A=)viQNgmO)u>e;Q`%P3=5!@! z3kUWtYs6NlLGYsbao$M~#Bs-XDAvnfS<1BGMA=0@5DC+h$pd?|Qthy`o(;uJv>^PX zagJ*3bv(uHc9|z-6%w~awI%B@U=6KSKnY>s!2k)K}&M=lj+8;H2-7$6w0i z+H^v#@250bmE$s24pbe-AS&_TTxQoykKgrSRuoRa%}A=2-%$VP;@8R-9|DuL>N_$s zM#LYsscfP!$pjwt?&!I+;lfvQm1xk1&= zV?!)$8D7;t0|dSJcGBE$land1!#b=3XR%ASLNk{Yh8Di>t~wRtL@vJob-<1-J~S)i z|9AoXZcnre=w`4#@H!o5Z$XO2njqHjw;=-$w1z4`nD~NNC3;oilYCa>>qg62&H{~g z$MG-=kS1ePUb4p##Yw8tWZ!Z*Zh)(32>JUWv*7KuF=gBl%0BYxTp?er;Iecb9JRF1 zu+N{4mHOY%p$QWmc}f86&-a25KD`L+>{as7?v@E;B*qJ&N}pe%z09%JT@h@?JOy8 zwe^Dp0b54xd>FmGh%0=Kg9q2*ahyu~K0fs(TBF58t$T6pw@vB9n?rLM&|Em_7XsAM z1PfIJanX*7($-h{ul85`H$IqKc-F;!xg5?p_tE2 zE=^G!NMv4_pGlDPI}|Y>_V-u%M_V-!@i?Y8iexZj2yyTio<%aI^oNt_2S;Xm0kl5| z6V|ce^%UNF-$a3<)z-U1%PlUO0ceYPF3(XItztDfvU}N2*g(!z zmHGVyxvd9Hv>(Q#ZmgycBJ$@O9oy*|9qu(YKWd@|b-aFnTA*c=v)L<0$#S z9gN*JndHN#)9kcJa0}5^6yVP;0E6{%1l>me^mT`Q3wFqO$TGPMhq##cc~>54jpcD$ zh}-7XcUy%;e@1P~B_|dE_anB~4GydJOWDjsJof3M@&3$eA6bUzbeATR*2zDG?N{!8 zw_jUG0EmSi9N4Seq7c4{@shWu>-52?oFzYg?+Ru(;Yp9yd#QSL^&w$OpU~>i@IzKi zM}N{Cqvs`-GZgg>C+`~EDrr?OVSy|7zom4FihmFb=5 z`9N=8?%n-DFkF%J{xy3($3;8zUWSuo!LJo_{1Si<>xSK9+%am_JzLav7~?#W7YyJq zL6Mw`n(x#sA2T~DY^El}$slHkiX7srOUe%+uGsPwE<<<@w;=<$Sz1@Sy=HeANAqQl z+o>KOWzuQeL5+h6=g0H4%D7Ahwr6+(#R<;c-|VhzGKNxxre6@BO(>MQt)RFp`gX%% zS+bYw-IK=s`PH#?e1LalWJwwy#M`ot#`^x_G}mS{F5<)|+Uc)ok(|;+64M1qS_oZS zo*4Cwmj(3G(CSg1@N4s%W)ci04zteyYHjjChifd0(~uVH=hxlLtKv~NWx2_3{4dm-rvdFM^>v<$uCby@{#Uf5-myVBwvj*cJU4PDHdjlpmH;r;tL|VJ~`XSRP zy@r@@&Q*U8Q){mu)*o|MaVt2FCfm9DrHgj@$CWKIS<|ORj~Nto+a~lYqyJ(7%sk6N z>J=^b`qqnkl!b2#;{#@%x=@hCJD&WvdYrvBoIJq{#LjFSLO#Xpz}{aO{lLBa^L!Nl z#sMNNW=8QFivxgK`%o94YHY;>#yyu|VIr78QuDsQdX}CsgtEGi9z7i3jUnS4@$UD1 z^gNw(6H_F&YZE=H(D(M8a8oMZI1k^PZ?;b#Fla6g=S*ku{`C9(0lPN)rm>f!5ySe+ zU5oOS1f<++Jw%KlherLC2?xXX>yc+h;pf|@%y?Pvn^bm7igpKYW05%5dnj#Z?QxtI zNMB;rl>WoEC8!`Q>FiixTJ*ER@ikBph&Tr#+Fmt_TA#*wAD-$KuUq#)EbtglU#TmQ zZADw4;sKTPIz?L7jj45VE~E0Qngt^d$Mn|H=GG@lLCDI)Dl>0{p17c*)U5fh;cb@@ z`{3=Kuy0#X+{)*q*ei2Kz68STClk&I!9QUd*T0+wxyGH} zl!s&7wm~i0s=pjf`=;EmPOOr64QXIlGjHF56V9GJH(SH!E4DJ;Y-YihTA>t)A4<{m zHl(~-T2L7+b1i1MX$u!g9+s9Kl&0Lh-Z(iCb-sPA?V|mKX9Ov<2iWxkh!N7(AtKlP zMs%&kqi>L9bvQ;`anJCzhDMs!2L*#mB0v{x=u%1Ky%8dx>-I@WhkSpu`#w625T5Z&Kk(sFD%b*_D$mOFbr*5~<<-~ScOrk^W)mi>p&!ny6{%qI<{C_EMusO_vH2Xp$j zyUQWdvG~L^`|$o4!v`8UCB9RqQoYg?h1|AjupX#n@pn)%$vCex9+iUaQ`s^%A zWRU4lB*nYM*Je;+?u!PUJ@hiDAq(U7$jd2&e1ozL`okwgHN4C|bnix~u4X!W2OzWpR8*_)4Jh5Vc;WZNw6p116-%+(JZr z&clUb0K5VGPX=UhqLfZIdEUe3q3;WZWSarv-`mUYK;a=*3x6pHhQ{tm72FkCa$ ze$@GD5auKNnqN>rz4`K93(@cT+rh#i8^Q0l8y5$zzetV+RYb-uo9cW`s$!rIc#eae zqZy>Ta=}@-snQ07Q_XSl{< z=hr13p2umehHoa@76bwh9#d4i&kTL%>3SVm%5|nwn@dQHgZ!EGZS|OKXBKANZ`(@`T?a8~yq3sDJMYD6< z_>Y+!Y4!%s^S?Xz9wT{fr=?wN?Hk)~&m!k+pGDh1;xEHu1)XZ=p{q*Cl~##Cz1wHE z38j+*6AHQ;=mjG67Uq0)bFi`J{ zyVnW2`xrYeBKfG2^Us~RMV^x&1hoQ9kP<26JzL}=%w7Y*(IJz}HycddeZvWv=ji_* zO=scO*bM5yk*?s0 zf;!hd6*A<4H*RM$%4;zIdOqZBb#RqCb@SyZ!Xv7@I3SZ0F>WI{j0m0seDvm3442O$ z6CoDx(R_PT(CFmCb`_aySXRbIc=y|Vgi7V+H%T7AZfuKf7=M74dFE$YYLig@4!hq% zpFpufBZB#EVAHdIN2_(3URdF&_?k>yIpjaRyidq?zAo#r3bL`U{U$dB>fGmO3-wEW zv3D3KA;;CXZ+07ZlAddv+QrOOuCYBw>#4tnM+d={w`|k#6Od)NTqHMmnfII7D7ldz zJm)bZeP~E>cLZU=uIv}TxN5^%H!}UY=$X6|x0_VXE~@!Mra#;6**@Z{4i(j~c(2yq z*@iUobe_cX>Axh+FHQiMH{W!C=((0eql!ph$Tl>E1FH>+_hkX1dC1&^@cBR zuHFnf)j+JVo2dr$TZ}(f=mXiL6`7pM=XGin-Mz9UPN?O@T+spdQC6!hPGgfdzzNYT z!=>YJ3YcoXcmH6@Lh!KR_~Vt*)^SaUEWh8^n-6f zi^p!6r{iI~!P9(N?Rp=4EqdQ(9vGh#75a7-jL?CNg#^V^pMw1Am;HNCb$z}LGAo)L zHzN90ZS_rU?Wc)nlIgy&;#}}L63GA01HB;;1yyKto2pP&Pfle#%o`gHI*gZqTEzW%{Whz0Qbqd# z=f$t)(scn|>@do5VT~##f6m6|_seyLSL`XhB7a{JGZ39E3Y{zmuD>#ZNt5l|m4kT{ z5ACi)nIF@z!oGp%# z2!;l1+qS3Ox4tbgHl~cY@jh9$y75thOoZJ675$?!l;*6q_6c~Mwk=U^zm#+x(WllT za`SQgEZu6ftCourk&O=cyf|+pA$Y!(34nS6qfNTOZGuwcVI~#2#`DMGEwLW_9U1!b zL4iDGaqSmqVX~qZc_>wJq5#_>f7lU^1$CllbwkIlQJG0`yVSc$!erzDfG`6Q$bG|- z*>TGCeW$jqig@tU3O<`u)ojd$5q3xo|D%a$hX!Cq${s;jjvM1yD3jyQT~3^a0iU2b z)1sYh%B+m6vRO0h5(2X?g{b}P^XJb73~IQX{b6wBw5BYu8*d!RVQ|}ucZlhh8jdwp z3vqw0$(UneH!gqc+Wor7_c-yv!!@136=TKqRMl}w>7Rg48Z}!C-%Yz7DSTCv$Xrnz z)?EYA8MHPb0}{?QO6zfWJM5ANEGO}{h3q)J$A8^@+qeF~eY z(LHQ02S^4R&su=5M*OFADp3=?$BT`+Cc!RQ941XBe;uMX4HMjJs&>cXgv#Ig59x-H6179gYp?9gT%Y$kjgy+BjHF`D%$%ZQ3_`|orfr| z!~be{SW<#$RsIXj^%dPR+n%HqAuHtb-A=nX47R$0xb7nwE}5Z<1}O|)oYR%wk?W5ZPoIP2FdgTg3k_< zFC74k&CRxQDI`~$LZD2`J5L8crStfDaoThn*KuzV;3nm~Rg}axUZA8F@v1_!%Y3XfrLM#T~mI zU+&5_psx;2f{>ACwshUQB#2>j*HQv+!}jWICP1vZv*+m#Fs=*6x}p_DL)t%ncS$aM zd8Lxkz_Z5b!9U5q9l|0hWKFJX;#arF745yYNUR8Y!=u_NiENCQv!F6QC}LygG)OP| zAlMeI18c~;kXc{rqn#T55kx6KG4NSp@3G9yL^n*`sDaBcS9i5(JZVSO&_%DxP|T#o zmPG$ZLVX=}nZ46^YmyfD5H#H#nbm~CgA~YmpmR`dj(V8}f(0A@ZLd1e)fc2h-0h~t zci`6@GWQc(8_v=I?AxG?6y+CeQNFWl)F+u^$6n)NTXN8np%9_!To zLru0Qwk_w=#c3{sP6I(vG?<#xt-h_^GVOe}1k$mko)jNQ@= zhpg8mFrEg}CiMj+b=*i^l}cpNrcZdb_$r;#Sk3t=>pxSkhZH4FYEo6uyN}pcM>o@t zoXe0U0pWeSW~;G9M_A@<@Cmg&hEa~)--$r;ix;+vX!=biHM)&wDkk?4Lq>)1ObufV zC4S*k#O}}04dwJc+=%)my9j83i<3?4{M*XUaDlu7rz@-bYS6QNQMr*%gtg-vJr2n% zQ(uj76Vv9f$wnG@6(+4y>Ft(R<_k@}*;4Oxxh{zxl6w1(0ZtXQy`0we%B$MXDVNCc zdg^VO;c}r#J1Uu4p1>-WF) zTf2I)=RI;BX-byw+)ZnNOnBcgS`e4-M?dN%`TZD+EP)|YPQPeewjMzf2Bgl;K8v-> zQ2bHb2*q8^$ijmoraN?161!>Df!oMcDlS)Z`F&9X6LA>F^>am_$nDE51g@4n_&nTb z@03za&_3U_=!M_iYrC04j%1Ke5q#t2raEO};2Mns=x(bhR(=&1HvCFuBw7)mQAe#? znFOhIFgX$@PSWX&QJf63_A59SQ&1~;*;4eJ49-u7en$vg0Z6!}S0`q!)CJh9U+a6QKGZ)jwewg^qwQ33m-BtL zkkJu+Oopr}P?{ExrxffuepYcJI&@J>W7V}?#XCwrl414Dj5$A*mI%z240Gfnw%3@? z5Ju4E2LM!Ge`j4pkfR_B%G47 zfvMpWA(z$gipSW)%7;gE^R9nrg9dN!y>rYC1F1~Q z;aQefVxwOrrLfsorq#X?KRmgIaL(C2r)=euGA`A8yT?VQ1)+#LPt9Tf_)aFW$e{Gf zpwCx!L(pyDP}5(@^8lvM1xd*oTzlBvwPK zq`qVBxmji+YJkca2xmDeA;;3^$W@D|a=cDW!Sv`3Qv}+FT1am=*Yi0M5MED>AfHy9%!cUA9O!zE|!L zg}8o+5NLEo78+&B7Mn+qY8@tYAO8)zQ~U^YI?S2)>7VivLPKQ3_x&g$X2~_=5rx|} ziB*wh^mjqFZ2unz*TSRH<6^hJVxwYG*o|7~Rvl%aLiJVYB3oi3DU&osTe-1Cp>yLR zh6*n0b0`d@dm81;aCBqo6?1XBcA?j?3#C?2g6M;V7W=So9~9V^*7x0CFg$0TNR9?r zv!sXMZGNYCUhHy^=N8=2wzD?^NW8UgPEB_{VbliQcTH~vZ{#e2sToAS&vB#f`bcFvJ~I85js>09Jg-X0oR`I=(Mv5cs$d(aE;ABIe0ZH z81S$fel{sl4DStcQ-t|%n@7E&albiReVKT65}z!@i#u3jvwGgAPh_x1?z~SA5Eq$O zV5&S=)m<(UoFc3zlFopXg-N{VK3pyTY@^x3B$4BFFgG4lT@vS`T(3|%JOPjZvH<+U z?MMwbBy$AZ<}UMuVgnq#*VNUEJfh9bd40H$m=kl9-CxGlgf~uz?_Bu;rPk}6zl)1g z*pE;qCBtS`mb*2_yzbW)TEE&r1ewmB1s4Z?VeMDfn4*C|&VRyF#?5|?PYMM3(POnJ z;8LZ;$XA-i0=w7>QJiidLv^T}XMP+*5I1Da2?9)S-%dFyPSJI<*c*bXof%%N4*0~v z0j_yJDni7!e({G54Y=f21*y4(c_WFkvvI*M7>}u5ZVjooWX@+32vO`KxVAKqnyQi9 zy%b`*QkFs#H{{BInw_iF2{#VVMY2BgePp>fr8n8{uM7z?cz1|cc=g}h_n5aLJ(I4J zm&!ez=~&%-GnKIeeC%S}7RyvlBq#2uAcXq*} z)JHJd7JyHLe-VE``>kcn!BRrJFO!=^VJdYWv73B~RoPwWh%nN z&EXafs+#V9;`Us`g>U&x{uw#xVGLyD?3$04H)fV1m|MTi?5lDe&MHohYB8dCyVzOi znI*cg(|g2$P?;9#XM;K~hi`Yl7jHlyk2>ai43?Puu9%D$hE#2D0#(1D;*wR=Bzv(6 zrU99Z!z!CY3Vmw7cj}UsnV&j~9=E!N)d{`O^@uY;j>S+VNkW?TdKpD@(oV>>aDSDI ziG^?N+SdAN3yeL$OCbbO zUfgZW4zMO99#B*SaT1A!Uu3%6D(#994No>Y3|?@R433N+Y~GvH$8mwtYP$h&-@*1@ zlcOtl7nN?8kLSM5kje%)nJliqX$J{iaT}bHQRTd)S=+O3=$@1Gt3M4{d#9+a4hkoF z1$iz~4TB^?w`{1Kt{AE_@wY^WeILBrJmf24TSb<4#?zZ3{ZZrP# zSenX0{I`339ppO^?R6(LLbWiSkAvK$_Uoix&z%+nyiVLo%O$xV9SAv%rI}8+?Kadq z;;%liM2~96rKwWpIXdP(f5ow`u1v#H>=)n@!TWTf`w3ByJPmsoQlpZKH_HEArjm=Z! z*oeTZ*1I6mj|o_`X`vsuILOl#8-iSF5Ff^u`oR1{{#$~fqtpc=o(GsHO=A*`B`Z z&}Ldue_y62W(_>J<@~3^8wH;^eTaibo>FPSTUjC`zsVemMoC$Ko|}O#5(eYVimH5u zQ@AnQ>MDu5a&Ow}A9Q*-2hHO`e1zN-zbZs3@zq{xRK;1m{q$m0-6r?B=Gn3ubv0^Y zjFH-%V0ek@g%wme^GiFqH|2*Rb1F?RqWlFwWa-5{LREve%H$smdf1z#FiqZt&XcoOGUw#WR9&W zfKAqR;|(sNsomu27*dVjEwDN6P`9d){#Ci6g}(E0jYMgt;dL5WzfL)tzwPF-t!thU z)JKgLVwWw90e_ilyV~|F=(0VWnX&nh&l;M}k^%;zHV-&UL66nMTqDj{$r^LRGg~KSSZR zt?H|iN)LU}&Zl~Xo>9FC&3=`IkZ|1RHzMDRCCRYie5- zE9WJ;JMzO9^#|)XH7WK7M@M_9KF+T>Qe45lQMfDPrF-mX84te&uiv3d2m<96jXeK* zE4hMZ+xQikLLrAkBXONhAjcfHt**XmQ zR5~vPI^4WG=3N-bkmDMxxbs6i*1L$6n*bBvZ}tPR^5^T-k?I?+4@>&F_-RdeP_2 zzp3_6I)m8c&^dwIbwxX8`n*S}ie|I;OxxNaGfxXUI+2aHI~BVGPs~^L%LGeeXI8b@ zoqaZg-C|_h)r;Jvm&PVtGrV1g`-{DWyv^lbkLkTrqz2DigthA4p_`+*bfN9Fji7dg zS7ct`>zag9A(tR4B@hnnR9r6a86Mz)mrqB1S+;wQ%Q2*lel~|x=5||IA>`L$P%V*u zurtvAng2_m?}7{6uY&0=v;xHyaCk!(pC}}$k0_Dl1gfQQbz-W~d!2vYhF2XqJwhWe zmE$K%R5`Gen|+j7={Lx#E6H5H%Ow<1B36w!ZHfQJn8WL4+xN|QY;-h5BUcnP1*I^9 z#~gfpu|DK63@wBvl6cL@>oEyqW0a2*OE|&lrQkQ@;>ji0u&Os9f2f^UgRi%W6iRo$ zbSvBBo8!H+^q!rkV3zdi$_eTc8JJM_``{O_rh7B)BQM&y@`-!oKfLUi)e(aD#X&2FO{no8Y{D}#%#_oUZS#of< z1?-I*mRYS-crd{i-2V1_1$Sq5^$2GSn(Ro6(;9fuayKz)N#>D zvWx3r93RFf&`63>5NA!ew1NQzC;#c+Q(v=xMK1PXvop-{YyHXm3BpBQK2pG<@*5S()ta(fJAfx~Ssd{lcNEeh z?CP-?Xte7&PuPV?i^u|1tPoqag^SU#(r~!jWB2r-2{e{B8M>1%--$(*4jr%L#fKyK zXz?lA1hbj(r2Z3wArnwirx~pSH2gnFtyJ+!)m{$msAAkc4Xai@Kfq&g#HqtqSb9$3 zE%g4Uj2)}-q=m*t+<@lli|Vv?t;?RB3)P7WyJqFmSXq^qJ=yBQUR5h2NagCG`<*>V z$CFA=<&&s{1zx|$ZQjR8%77pj8Qa!amS~`tiz}J+Ro-&zph?fF`AA~^JzGlMw){KQ z+l`ch9F-e-Q-GfyQ9+D`vODk{#IOJH_c-QR*LTJ)H2q*uGf&u>*;0dS)k?vm$uuU z(rG{ab=i5IsclV1;*r7vakg)=)#zQwoyc9gnHi%9@qMm~;V}n%M=QGf+B3eK zdaiGcsU@ggllywTD9Y$r8tFUhQgmbs{mYObuuX-JEMR#%MZMx&V(oNiH$*RS!X3;Q z_z^>Bj`8rak7I5(!DSd<{&o1Lvoe+nAjC`6R22-O$_KBQZsj3wA5Iw+xvmqbg{971 z50boDz89pp8I`Cz24G+RF8jqgNN&52u6DAJ9#MAi62*1}a!#gW6a!tDd0 z{AA2*_4sRmy{RV%P0al<2H_8i+u7nL24yt>4*|y~_VYG$BZsQd)MWQHM@aX< z^vm+>EW8#tUnvQ8`d4|RsPU@A-9SMh(rEc~JB4Y~H&fF2{qm2)J2%EK;q%tf{*9yw zAn1U!I!WviyROTX6`S!saXwh0R^D_)QdRBotGEiYWz%%0?Ah!hi~Ss@ z#t+)|tw8W095LfCF&u7IJ;=i40g<@@lVB4G_!KnxQfINc?6!5i6R(0-v@h!w`4z*$P%!BkoDYVM4n4JybhAd8GQ%PL!Rh<#A|g$%?IsTQ<_R zDK))Shs*G-ShGti*|@OgvzLSVZLo{xy^^whmV7hpZ@>Wa;3J5GqRD>D+KoV+-4U!G z=6d6^01M7PJCG;&%>0PjSTQgP;C?#k*~`*aMx{l z%Z$|J+jZvAZNdQ@5$t7~{ArOY`4eImXm5-EatjAf39GZC^X90Zw36(yFaCg5$$h(# zNzl0>g@kjb<62@{EL)&H43d4g)C_Qu$`n8XD8wBjU(oQVqEX)3zr^K^7uqyma~ZQN zHAT$=t(NO7N=uD87C)^_f-wIeodemDq1*gB4u@GQ%=?6<6D-nj5$O4SNK^6_b`~bS zhVc)Ji1q-&Fi?fO{qtCU?2sYHD+mnTJ1+Q#epro*6D7LW6%^ztK$WW4y z2NPphxC*WlUBV*yNL+uxL}6>UhrxY~n{IshuOpQp_M19ds8Nq^YV$k*LM7Z=jDiP< z_asc)msdQ)bLMN(?=C0$9o0LKXA_8-=I3h5t(S(yJGm#yxzBHg!Zd^JHkZ?#2vLFC z3)R~{F&&KY5|RqEe%1~A?T1#G!dA6n*>;b+{YEJycCa9h%2>TWw(O*vo$T!!NYrN} zg>V<~{0EEOp?^1Pp7&?Pejol(Ib0Ltx&CSy zQ4tHUO78n2bclhtdk|2|j?Q2YuXVqY-!yZB^rYOG z&rvgTWUYLCFt9!Y+jrb3B5?VrBFsrsH)>)oHqx0`5x#IWxmM`~(S@8eqxUE^UOXDb zClYuUydq&53+;iTury}^tzn|1Z1dSZv(O{2)eP0QlpPVm?y~is(g{bnR1Ie2j6%|g z#hNhyr#HMxVrsXO$ZEM)KJrdt?VcBkm`YZ^d`vxME515X&lSY(?TM1RI$W?>F5?pq z?sC2!TtJL0zgjh75Thpba~LptG{ju@;l8lgtys&a_&^&M@9H06CZhX-J3O@)s}dL=B1Q*$i)$i=ZlUP&Dl+1 zuNZEU3zla&tjxhwLjoYQ9MMhb_p*?&Ku4P;jxs@ z2i+j!?`M;28nV%QiIHXOLeUM!9_+Xpk?+K^tad|1YAq^GZpsHYs%jII_y}r+UCs9N zWjnjdlNj)7Tpe|LYFBElT%l!F*v^?W#_-A-;N4Nq>4Ab0mEE4%gV9Dv@04IdHt*~9$9gs93;@q zRrsB7hSiXg804R2brw`c7D*=g7SN9p!@o|5nLHzBGgWF}{cB_rX!GT+wzts9D-?ym zP*Fyv4@;2Q|AtjqB`w(1`{GCu$vU$_@LCp)NJCEf!BoFje;6xr5p_QR|!(0Pv^ zhngTH9vxD=#<{}7vck9dq?a5#36BWQurnnkZTP!|P zS#~yYws`rygXU(BgA9pq*H!KKBQDr)MT6De&vJMEcrRw~178ubhcK>)36Ttz;R~nl zE2gD(Y6_%3r=)Qh1@^}v=bJn4yF6GvZC~lw3>#`J%?VU934y&^9hW*)4`IsM>3Y=@ z3YmDHgRmrQ!t-hxx%cl5(55wCbjjjMZkw{l`F)qu)BTK?Bmhp<4dY}b9Iw>`j{Ud! z^2;K{Tv~4@Pc>)+^m-}&vciq_jlwWssAUL8?rri%k*k+wACIorDLPoGbgL75lLxZi z$1Yy(G(4{s4DuJL*Jl^`X>CkqbUv5gKco5=?{EzUMjOKPUr*e~qVw`aT?AzSW>-1+ zFkzWa&2z@Fc^10-gbX#sg(RAgZK6q=a&dExp|!2MCD~buZi5~I^5}n#$u@G?5&!(O zifZ($GFO!rCwsIo-LG=|<}NY!0V1)f5>{23ipTqZu}w;J&X1hV6XFs;*oLM;rzW#y z+h>5L0^`a+w^w#<#}MA#+lc;;k77^5XvWhWs(X9CswbaF_)x{cNsa{X=!m@sOB|YeCxM{&Bwe3hb(O-GOzQg zbyj894aMH(1l^Ff=gGu1K|g&JMfAXt&#Y;u#jr#EQN<7>NLU7KQahB*cc1&@PbZ&X ze}?8uE@R-pLqksY6=qJiQ5&+-cv}1RsJS1ksXz1ScFI2{yI0vcY_?3>2V~A~9qDjk z!rrA46VKOeNTQBcY7yJTL_@N0ucXdO|6%tz3D)}lQM>|=eJtI3VTjsB5n0W}8JCHD zHu4)^C1H&oD&B8l<|D*;H0DEmcSh`Fk$G+L0Dbxz2WH5Tj_)1v60h{Z*RB=fq8|n; z)}eyhL+-*#3X)uJDMH?z+ZPv~s|?;h3x$q2luvhkw=k>2a|*0@jPpo6Cz!G~)5i!W zxS`kH7J_>T)d|YK`4)pYbj~~vw1L8BBNyTijky zZSWmX?`@MpkK?>|mXyB+Z2O#a-q-cfuVD#UvnkcIclOJ*Wa3$t*o1f6hBk@&Vx9@% z{m;B^AGQuDhhGf2x<#Yjoz%hy-e9o9OowIc*E0AeHT#L76GM~eTj>3f&B!u75uP>R zJm@dOeV#KA%V)^cX%dPQC7k)Apl5Ad7^el+10m;R*{`nzt!e*swraLENrLGnzyW+< zpff6qPAaC7edfB94i$-v)8^oAqa#e3KO0x@2{aezn~j5BUS6tE%28^^CsSUg z*?amc#rjPQ55?XLmc@b-VzZk@Duw*U4{7*)e)6}P|F^t=rK7Q|OIfg8@CMccy7Ohk zzq%pm*^Y!@#ca^c#n-x+NMso(@8t3C2i`4P)PO{|Pg{4yvn(R2y}=#{g^|^cTbz0# z)ieq+09Td4IY7VHKE2jXkR&LbNbRHWu8v{%ddv*`A9A59b*#to@e} zZUiiEf+x`vBxRC~lG3_04BKX)4!qQzo!FfGR#?+lJtO|fSkU{3w&D$eyr?n$H*RsV zdTjx*75be9K^VIbj;z}EIvh7F19$%)@HweS=QyQCPN#;pjP}7fDjCiv)qL@5MdkkH z@(HN}KBrmqhUC7D#BbUo1`z=v>;j0bZBS|A@I^glv4vDdrty8LhC0=_F80b!#0VB$ zG@RdNS^VvMBL+~q#sSSjzQBEc(cBTKtmJkQDoyCuc=+`hIDdNJe$kGS@-_pV9- zep1U4C}W+FSX8tB$Zi7~?GX=WT*H%GIU>zmx9uqZ#CgEJdYtwYBq^8wpzXc&?*Fua ztKajKkX_M9e`%WQLq&u*U-`mL`Vp|%D=Ts+?zwWFdg^WMR%di9ItafC)c zqrI(V3~JWf9516uK$koFDmw3-7Ti_eM=iu{xTh=Y2JcX^WEODdfJ|;0>^O+PTE?`k zEm`6--_tJIwq$%O&o8GAb|ZOR5JFnDixB3Me-~4}xfuZzt(}3aUxWC{o&J$D63iax z0+Z6=ypF19ty-}@vH*#>hOCH>s-n|@HD^Mqa3 zvr}VVKui_r5S(gs|Lal))b*VnghK^8IP z)Unei*s;A(4oI$~+WKLo$sX>>{YAm3)0(^<>&C?&e$ z1bhCL90uu*0C8{UFiM|#zFo$%i^Wd_LwQ1FUzH6vi@0u4Bq%>UL-)gM zfv}cZd76#s7$_MaVb4Nr7<&{^zYdOTYAu=z2)jur&(39lY5a8rNm28zE1-66Z+GId z#~Ft30OwyY}H+ zPsS-ERh)f(!1O|?I8JVY(?<{}zMY)c-b#DVQi~@Wy`PTke{E)gj9&X?aA`4yB+wFFnqkwsMzF!U)8?`Z$Ija z^c!^mzi~RjHn!Exh+(+`#NDOlP-NQM*G-q#BnuI4=X13=jepeUAO@_{V>agnN zBbqp=ilh(S6tbi{mw~qMykxoCqdS8~1STOjmSgn$b0a~5kZz~?{55u>ww*{XbD zGVb6LL%*S|;GKX3+c8ylxK>X72&1wQJpZ;nmc<>Vp2GN}CJ~O4rsuK!JIQK$)@Chr zKr)v-vpP1F@UfyQww`392cVH21-e!iC8T6*mPB?M1tH0_pbF}Hygo_dymvOq)m!D@ zk;f-${2R_Envl&-{-)tlut%klm#+7opvTXY%8vqb&iRl%0_F+Fs0?@O2K>MB{hjDy zjtt%jxKrn$JE|n%uD2eq_ zl*Z9`>P=AT$N2_VWAp>%KhHH%ZZ3Z+FFXk6U%p9Vl`P7s&rM`xOMhLy7grQd?9?!y zo)a9Jb8I`^#hK)1{vq;ms!W?j+p|JwTJ}piKZ_CeWN%sK+B>OLXC%-1sWS;x9y~BB zF@O)SzUeBK%S6a@_Rx?Tcd7``8{O2fX)Lq4E$_iq3K-;vUe~2mYpu|)krkmjG$Z8d zWUGXGW5jd9GXyjia09MRq5Q^DKf2-!8SdZAN{nw5VP&v^)D^1&q;$AU!9{Yw*-NC0gbvZj8FhS9YTm!> zfeByr>mTz8ldlcvsVlTIS7EN?9L8JPOn6mHs1SeTdAFJ%1AM!@Br z6?-~a!*-#`1u^2avBB_Gp})%7-s6aPYScUlr@*V1tUA*d#e?+nIZ^eb-7wArObYsk+lr2`a9n({^G*rHZ)!sK0EPWZ0|eLWSLvBQv9F; zJcpO`K*}Tz2XRV^Nm0@$DWxJ2$jYBIZGwl+YL5MQLE${E+H$9D#mR+MUQsjoAiSEV z#Gffb0cMO1=5vbQKFH=Qyr-WK%pFTew?t=rZX=9~%O^-+FnAd*6zJ;Y@VwV1&y9*AO$?A5{IM%12e zo=GuxaHayj&Kzr{9agLbAkM`zacknIUH1W6-vYsjaVpc}m4vtHBUTx)aY4;jyavX+ zIkQE;xJ0cTi4rZGs^AM|R+D~NAN*>ARxMC41jhu5>4@46H3YuCxxc-LD3Ks_6uYi` z`EGlm#QS8?sgTf3HHF=7Q$wP^mm7|6`shkF7y?}Ji zuR$>`xs7&nRwk{zS=O-!fs9=5?DdIH?BQCp3KCdgz&l4jUD(D@kTn-AweTk{aL(BEs@lK`8 zc`jMlMjs~OIhN=8HSBFPBqm2lTAM$rdY(0hWxa>p6vbk4xs{{DC~XH>&}*gR13ZgX z;u*}Q-(5Y9DBVsRkg6NsBccU2XP0Rq0iW#twBNCZmy|u$Z<%`bHmnU#t++Fq5?%+RR!fXkYuGi=RI;=jmv(&>g6=?5}B1cT4aNL!?( zQz~5xWt7}qeusaWB74~OG{c3xS0A6>ewL`)K>Dz?+p6d7xN#iz@7xcLDG?$Hn@jH9p&1p*RcYC&bewBUpbn;rA%96B+tIX~ z$fSi}(v;jKaky+-Hq?`Bk(XXNaG91&nQM=jS1n+Oh)GA^E&*IFX^Em}E}GO0 zpNMV4s{&=xMol;bCO~~DJ-y~<74lezAJed316CCi1~C-HR%%G}>Qn7N$!tcBb8*pb zbb*+wT%OQ9Toe`}c|<)_md`93%5^NTcl-?Ief;EZcjc_$dLe#rU@;EaTC(!@%WT@a ze;NAEP%oY4R$W^lkS+Sr_w$ZW)>_miDf3uvln>(PHW7~AW5*z!j9~T(GIl}2IZ{WX zVMb^{pTe-g2q(C;_?+s3Jjf*x?{YhG>u*S*IUzlu<7|?eKpKb=uJ%s`B6_hJM>+QV z7a-T{x5p;Ur$&oIzzsA%%3BLQbXsVzPX0y!)E~F_@{5)HPMSEpJ?BZR|IGcI%0kb` zhJagdv?3*P?hN*3&c2of>!{@L;9tlP|A!l5OQ_I4Sy;?C&^tuQbLuJd{mfJTWq}0) zF^>^zmxs5bxG^yiL|2QQKGiva#umx$-M>#_9Ju;Am}vvn2g_(pPsebIko}X{z_9!qnRvZ5Q8XW_8(a#MY{<9r_ON~Ew!$x@b#YR%aMRc!zZlTy{KHIMK zjb_Q^clK(TOvAP{j?}?~WaQHsCQO1itM?m>1MWz5D2TWW4In7 zPuF1bWz;sZ$!%*JJ^_+K=14{*{nnMb(uDN<8a7e3$6Xuo_$=U$6(-6x@>LrN5^;Jx zAaeg*v0HEMu}>aTD>$v#@qZ@K>F;h(AgTu|@HhM+KCU7V%aFvI!d!t$4n2vQNi`aT zn`a`J9l311CR_p&39=+h-0t)R)LSq#fulTb;Lf{D5#TuO$t-d=Z@2m~wc0O3)57@M z`zwnrZVAYenf8Xhu3$Sbcd5l|>FAK6pA0Fn`3Ee{;XDX{rE5ZjbnSD9ze zn3235)oasv{te}GiZ!7;(NYhZdc&A?rae)v{#!}oo}XefpcZyudK-3|M;s$wuedYo zv{;KI31T_4Yk{VYIEXsYT}(c^wcsuQM<LJ*lBfq`fYJL+ZCF+)Bs^@0V3$gJTWpO()rRAgc)e>+=JYjyV z8E~J2Z5W~7i1&3Rl`Gzl!g7FLsX~3QZ@-m8*@ang@zOxNYRAYp?=Drqo6pv-kAQMF zy-!(`m(q^~tW?stn6SJ(Pxy>n3y`z5Ddo7(FC!!75kPs3f)aoXalz)Yfe$V!4Q8Ph!W4PeDn* zw_vomy|xVdtQkmxsAnmZHMpCpy-Jw)MI(CqisUAh;-5#T&+%PDBM(|h^$s_DoborRf=P>(?|Zz0nfm2 zjKlJs4+@Pt@wg|P_3M^sfU{)6_9&qi5>-$rl z(-(OrCd2u9CICC`b65o2*-|aE1M7sSOi8MwMDHR*Yo5b}>}#!Sdbb#8){fc6j_u-v zHbM!wa3-Cy%)~ymnp4=F-{m4UE2JakR#E2uz8>3u5L1V9V}Pf%fQ^N_kYy|h4v%brHdFNBO_fj+$^MU z@68`96Etp}w+U9<_%0tF6@|rLzWBdDqudTkgVA~s`YxX<*N&PYAx5*@u;43-HVu6k z9+K!PM{38B8cAdrcdO=%a)Ra<-Dnu&VZoDX?@j&CWzU&FQipKhTfa-caaQCy?BXW1%L<{7c+AkO#2%`SYL0>Fn#3 zR$(h#T!)Ne_X!!1cVclclg8t{W5xD+toWWp0!G_sRT?a~A6MY_?wJcw&Qf*nM%GAg zGW0SgdfCw?d_P4ZDISPjiKV!4{UGsZD-4QW@t+NrH;bekX zbit%Q5!;;gDXyIDpYv&+!9pi7{id6BWMM3ZHHWt>A-!?;xO9bm86tvOusJmFtk&Hh ziWOgSo7M~pBD!WI8NR{9(K)$z_=AId;Y3~XGW7nwW3pm61qcgN6J%ke^u zEa=%TGe?zcMBkg|_|1TgUF3;-%g+5dZqIB7G3ka)4u{m@Zhv3w3(4*`t|Z>#8%kD3 zt(9)NbDC-pyz{numz1BM7+8Foy{MT>`HvZG(}`FO2B)chs-IlZv_0X8m|EvGZ;0Yf z=h8JYISN!aQtW*?tnivM^l;60@Ca98ab&nJuW-n~62ziwb9FKCntL?ARe@dNDi>6@ zNkpU|xOI4zKXMA^+lj?B9D3fI%f)yr$)>G9m_|^JZqg@oi~p&wxDP&)B!dN@IWn&& z%liTG&tXH@?EGgi{bSaKy?6<;Yz4mtd2BJc)HiI6iZIA*jK1?TvBV)K9+J>Ix8f{k zkk8672!U9_t5pUI4!@sOkV>PsPY}ElG+F7TH&y0y4_&(o_kC{Dv4aggCGH+emyce@ zc9L1P$cWV@xZaXD2Vp=_hO3gn+>Twe^tk};qeXcV)pRdgvUD#x@#XMSL$epxN3r<9 zCRV4X6rlWN*H?^bL>o2W8%%jW6@5-9E!P=H7ok z#pav*fB47Y2=3rdfi8@>uE_vZHNyYuF!`a2y5N|nqkr5-5%H5r9%1DnuDg62{t-Lo-XhCfzaXHpAsgJOV~Y)QRH5V8Y^EwpyVS@SG zH!_?3&hh8cDyH^urFkt}`;s?c+VTImI?J#&y0BXpX@OF#Kq>A}+}$0D^@ZZ@?(W6i z-CLkgDDIx(PKvu*vEac%IKy|&_vc(!{wA|$XZFnO=UMAs_g-_B5@V~E6*Foc9}ylg zD4#6P(dkZ?C%g92WHX6^eq|CG1^pY-tu!Yr3|dHV;B-52X85nPQ{PojD>%;R**N2+ zbrY>8>{s#bY)+w|^^zu=^T@zRKo}ePA-`bAPX4CSrI0A7k`4BO5pCqj1${)g}#H=A?l9GZ5;jjhw~APuXSMMS16z z8YRzVKF(`ALZPZT%ytH&M1?ztyG|vEpLY8$_`R=Ky!6UjkhrAsGVmw_CydM!G7+r) z%H>^4ot&UdAqRCNhzB0+mP!$!Ct`fetkttFtnDJ)0{{LlBg^D};WIP_upd6uwi|TZ zzKO%ayI<~5SH2c$;Z2hae=A>S8m{cU0yP+%IGnB=iJoqHx(_LRO#Ep#5Gei6Xul?i z_idT~=0h2+qW5vL!L-#T@=U(tZT^{`H#<(b_r>uQ*i4L-a#ngCo z2wM0=(C`o1%7sM^skjz|Y-Wv4SbtN?xk$72C$+u3TT%S1qB7Uwf2 zm7*NxR_;Ap=PJPj#<5kG#qcz}22mw}u#L~eSBslT`bY`N$uHw-RhPP=9+nC>xd2Cl zGFh%lPCPwJR#GB2)DRi;`I5TUYA4Vq=r4jyRQ}MaFBz{*b8OUWOkhV0ezdT5#IW@} z!8XRco09zQ;-JXs zie2Hz$#dNXo14~f(SRMsVOO;eOFh4NU9F2$8y=&!IHarU2aLBac06}h2J_~<-Nv4l%vgts8%?f@@plyUJeI_GUc%>Wr zY<+0>_=8uDAK2p{I>P8U3IlN^^8}y&U6U$?D^$eB@des0t}Xrbl}<#-mX?Aj@*RRy zfde?JXdk^ zZb&G#@3T_-i01FGP*BaG=R`aKdz_q@%qjKh6%X6Dr%TjlVgZ@{vmFiRTi=y8`xBa; zy~&_aZus<{A~TqG8XOFT{bHhL6xVSU8|)J8wcA9`@M&!@Iy_HO)`XBYQd1hoBsyOE zNZ7xDqV-1PEXaWc3s<)TY(j!{u3a#c4Y90WnJ!TqPtKh#M z1JeOvWhC*6R3L1gyn1W`y*Ex#2bGJD6c*fHjX&)=D(Bl;Z?fa5ZoblNFo@wUa2bWf zTgLkoe_NWJh%%&no;1ax=QTQHG|n-yV5GCssvm=QkylU$NHLLLW1QzEe1Mj?`HmeD zSfI(RWg}ncKO5F57FLp+eqNexzp_iIvd2hH3xsA+CSo8y zp8B!n!1zRGH(KGDadAjD5^)DA51#|!`(}rJEBDho5AA~)Z3ppIqn#sW%2WHNg&EsF z$B03XuJ-v2%Gm1IJ5!4NsOKnVgPri7=JO8pYq{>pr+UJ-aZyZ4y&=Nb!hsi|(^ok| z7KeFv1!ZJR{EJ$v9xG3Sq?x&eoQ@+bVvnhF!Yfb*AH9sO)F*JnoH9AGt(G8lWe*_N z)sdgYv>DV;2t@WKHjd`HiVRz^8+Gpc^OKqHgCqAH5}N8Bq!S{{yG}|_4RYQJDIEs= z<|F3&`_o?seE5dA3;EG0axvjP(4@KYEJ%1P$vUA@2%d%1JoOsgTmCPmTxh6GAE1k5 zP`$YyOj~RB$TasPr?t)pZmxuU7hZ>_*8XKmMn_@N38EEw;-YBAjvRo;v))nad|{96g1U) z>9s`9UM5okLRIni+JIq8iNT7O^& z-TlgDb7GLz<1_$G444NpEp<^(mOiczJyNDxcKmX8DVY!X-ZD-YQb=#P`X$`QYvU99 z&~P-ndW}P0q5k87%({GueYR=oN6+>_+)cGf#T){*+oX~qzxaziM)mfr?`r`U@d|Vh zUO9AY;cE1KJnUVohVFEe!Y5Pv0`k_fIv2{S9Xi&_z2=-l5D5!=@=rxlszQ)g?&?}8 zf`r?&+#32L;Z>yguFpdyNU0mzQb@gsTTJWdr5MoGHJ44-4QASj7qVIhnMOZnU7+s; z4~b=Fcy-fOQ#b9zh0q}BS5a_O__l|6L~nXV+n@5Zit zpVqZJ^nYssBC-j0Gi==|1tdI1%QsK*6=Y|1x%BHsqvS4)(M8I@0+UAdzZw1X;_NN8 z2TUT0Evg0B@Y)ftvjiULcA&KL`rhOYc?oWG9;u@)@!sf8ioONvXyw~#c8p1Q`A=`K z@Q8rVCE^#=R$TO@{CCdTJ+>1qN3^e#ga0)H7)9PsVQ7m__@K|e)YzOh&FufSYvua~ z;&rK^r{A8EBh_30;NATW3NKrjz~aR4{*QNomu(|F7pqzFW52m8wbs4{fAG3c!(!mY zsg4zifFussSpfB$ZMrsWQ}{M%$;VR&EkWKUWA@tPgWpUTtgzD~)*XRfHrXO_qpIB*J)rPyDCbo{YSaO!TZjy`@K#6t9e++5z)J3Jzs?)Hjl=3Wns)hF%^ ze(+F&rR)`el=vM=%am0x1iA-J2FZw1F{7ZHaK2%VYEXnVInlpIT7;7h<$+0y?X-dY zINKqn#Ptp7x86-BVKWM=(zl6)B3Oz7@lNA<;OwisDaOK$icnb7Il=u_DxVTg<4|6g zJ=kx(%Plhc0I`l$r@Cl$>KIAj_%b&-CEH9arDnrl1$w$>*4qO#ywl!hq_G5Em61#FHQPp`$ekX889YAOSX*r)S%QhT+6W&ry$JoiM$7?F6e$4pw>r|r;{Hq`w7~QF=-&z^S(H1`wt$w+pA=aK83+h8a}qwxUyRDc zCk~BRF8F6H;Ps~Ha>6=n8%~y+7OWHeg#F8v9$l~$&y91(x1RdL~ zs)3Z6#7{#hP15K&4#M*^yXP^qdksAf=971m`>K(ugliJ3SxGHX3t{0d+HSbpYxQ0m zfj;w7CAf0;Zj+bRf@o#AUn>GPd#oW?MhE`-r{e;Wzh2xQO5syt}x3a@?*e72L|^x`@S5OQw8slpL>KO2H0Z z4Ls=#tXI8VC239GjNrt(Y`e0$xB0Dr^|K)F^4x_KQ0nw>2di_Ocf8g!_qrRfs!p?G zt+8kv#MYhWpV*EmrQeyL8+*RQCa2O9%Gm?BTnIJE%Vl3Vq(5@P6Bi=xYAGRVheiHd|Hf>kk3Nat*9t(^O`SKv2l(}=YcTo(m~}C+0f#u` z=rTIG#gb?)k=d^I46~3y4Bq&a9jRRRXQR|RrAFfx5jW@t-IvIlIk+Me|>x@JZD=OY%H>(&=t1U8s@$EO5^|&T+^r2_gS%=@(KXw3?Ec zgoJluyDQ98%fC)sz0xQ;G3mZjwu9f?Uz`)5oQ)EhoB+wm91`pQmPcVYxYIC;Of55m zA|;<^8jQ$qe=dE-;&=%VeV(ZK1VHHE7Bslb?|I?RfFHXg+7W=3+AI>L739Xi_m=ZSun8K zm~)H8@u)_-D`obVTO|pA=a4-o`1JL!W%MG8eq&Ule`OQHOS`q+rKzltj!K3NTit6R zbuZ9Vp}E89e(rhVy2kt>M$CNLKJnf-u;+*PFQleeO$E1KQM5+g9o6k%cg96Mr2Q-` zI_Ht$cyz=#vG*gF)Z@(w*4S3?JAm|Z)OofAHopo&F-*qQs@SFMMRC3pki@{?D1q)- z)qEIDhE6wVDfGPBM~&3@WD><;iG}6SDZcvr7N41d7gB?ttE86Rr-&vEUo2+IVkd~= z-X)oEsk$;`ZysuKzxx9D$Gw{8FHwL&-10c7RVr49(XEk_=SQM|$UqM$ul&#)+#)ym zg!{~LrjM#`CpcW$Iyrkt7>0EIS{cFX8(TeU&rLRxHAb^s)O5C95nn;p`~W@vMzX!i z%&#rZQ}$#V@3vYG`i)DE7;WX(H@rkDZ?2?g$dqrc{+o%o_haYb3kj7_t4)#KJetA_k&P z07Y{56FunSA1yO7UBIRe_+V{SItGs^$80GCc;akH#mHZc+wEqb879R#f@aes)P-AD zj_dsnSaJ8&nq39jsE-Y;FtNDIz1PI-_R82wiQ+GSJ3U=&@(VzMVAZuw|9MzB;uCJP zX9u_u;c}4xJo+R>mZB0$&K*LI`~GrQ!d!<6&BH8KVXgw-6GqvnCx><#v4k`5I(~fHEHY_!!L}@@aNyU^d@%JX{_U=< z;8Qdo zw>*-d6J#NK&M7QW=orPOd~)yW`2D+y%H#N@*QEq6+};jHn@#KSymym9%RQd=>nlV# zD2SqA{)ac@aTC0BB-WFU!Y<(gZAdKF2U3oyI8GZ>!ZiE?4NuSK4MyY9q}W%`mi&Ac z7PjCwWq!Pn&RHB29d%}Uzm2zYBrNfI>kgMJ4?ZbMq&)q1Z6`}zilUIKSBAhfQ>q`h zVAgB%Aw2d2mkYGs=H~5=!ENF}2_9njTY_XW6}wL)R$6U?(bA+eKS#Avn!Ci@<+U)Z zL{BTNz`VyRV5NSJG8rdPrSSV*POn}Pu4yw; zYLi_Thys8EKbZqft3FhM`Bt(H&5kD(FwUD$Sl$Qwbg&Y@uB(sD;6zinkqUTt9<$(&Z&_efuJ9$C7CHC}ccnoS^ zKA9bFX$#wD1>J2M-aGaiTtZ`6UQ3Rb6JeY7Vh0`7TU4TN$=ENRPOB_H4~VzLEWP!7 zerll;^6YBru($p87r6FxL(Z>CjSQIqz*Z(w+kV}(i1*yG?pA3W?MmxL3W4MoByw`pIa^hK zeR4j$cI1=FIz))9eVqD|4p%roMPRcSh!UP93WCPwC=1Sv*>TM@Te( zJM5V4k*Oc>tKW2z2jZWz2jg{rw~;-;-mWWKY<_=C7j&!$f0|hsDGC>H@@RkekK(|C zU&@f2=TBzF+X6!L((t(n#2>p6$l)n$23I8wJsn~ugCx{7VM|qY341x42rXdT%tz7b z2Dz4j`U&S_R=}Y#WnHW@cwIEFkRC6)WPp{kIiXjdo7p;pbWivgJ*1gtscic;Xf+Ha zSo|JwfW@fpKZ;5J)sMraLSM4oqeExc@6N}%x8C_|t1Q(XImy{+D+_i%G?4@i`gAvn z7jWe^rC1FyCodn6djA&h23lu8!QJa|7N{HO61u=oH$bX`LHu9V{VMWt-&8d}6 z;|vxmW$sM!b7Z%>eKA^v_6fD@!S(7(34~fmy@f28Sahn>R;SJkaY?4^vd(t?7_4}W zF7nBa0VqsmqReUhG2{TZT8q9Qa~%56{v9Kk$nzOY?aF&Qp7k^WJbq8?`}|04l9Zk% zUCRfxc4@%K)kL*Q-N;&xk!4{k)BeXQrd30v+-X9N5&mqs!uiHGtx}>t!RH0jl zgUa!O4g$9r;KAgfhvKcGHBzXrU2xjG08g_yHPI8sSCpY42~z8xn8-k6qPf;km9dQ-3~&+XmC(Ij?cv(fxB2)uK6-`g!#-1$3L6f8uTxGWXO8 zD%yOpcr2C=&Jd5&k_oOe)B{QSN-l;~=O4Y{{$0-wMMKR?1UP@p#PG%s^>_LbDy%;* zt8+1P7&Z8(M`PX8+sXN(+RFyNlTcIvK_L_HWF{9@1%?_wHkUIF2A(LqePp!=iNgOG`Z-6zV9+zy6$`Iy{%3Sv@N?z89(3qXVjA>h9hT00 z5MtU+)Y*Z?MYOMPJj}DC+&gkoJrgiPtxPL~XjOPf>2MZ6vW&sxXM-UV^D5}@EO|?8 z%tXUZT34oEjHex<@&hZgIe=fz@1HPV+CKg{!E;6a_C2ZcvuQ=E*d{vnbzfq+Y0(s! z+xrMKZ#L^f5Fn&}k#|Oy`&S-dXhyphh>12hkNV>lu7nqF4mX&^4mA@7RSx9jpR6?S z%_|R71>O=XLRn}W4o& zIPmYVZ|7sy>g6~vec)hqv*AXw1<(7tN37kp+k&)~ZhwuyC%5L4#XXK-$uWOeGSA3< zDk9;M&0hD@<8JB&`DpqYD<>~F(?dXYvh9)EXLApFd381uu%|YV8sJ_&easK#s(y3E zL6-S!N8>2#=t^vJwJ6i!WIn5C@WcA`5&d1`>xXP>o9V?X1aQjUG42|^gAtCr4l;R5yyq>oU=7`rofWLliXR~ah z*CH1#t6?d!iZ1GB0-B{aJL^~P*Z3M+7Q8_E%QxLJ>%>>h{vxO7gbx$Ohu70<`^*+A zAkYGgw(yxnGA7*-^|NuF0V zqCN|JdNZ)Qx=iqK!SD_~R#j!#;#0Lccl^QUAz#j7X_S^uugXk?3(`txH0A(a|0LBN zsA#<*O}DAWHa?^JHf(yxT$kj>HD8X;^vLEK{L`N@pf{V6i3R_KG%`9m3N34P=;}{R z;I`hZbL*{fWk<|Xc(I!Jbe%P!(A&mQC552U6ph`~*nfhV;4TM(9);>H`aX1u>NMD= z#{~p5`p@5*-EZ%KORh}{u(Iy#zJU#HOU@$|=sKUPMzDT3EtUH|!RoX5fLrjUJ(gS7(aO^`?J z-g>i*1qq3J0+p!E6K5ya&q60mp09pkA zVup9;I)vQypcI5z#1ZsFNQNVP}ie%oK_SGg?Cp&d^Txl7Pe$IWmHMoNASh zfV}PbKjmC6e$~UC86H{@oL}zNz6nfh#f;jmN%zH6v%$P~q4aNrKp0?@p#J!PE2iS9 zJyk90F%k#X3)BPdtIP-1uH&~?WzB^TrJ48YE3e;LZcy3rl{4+K7&aphxTXQx&z$Qb z0oaf9iDTYuVSQ!+FS|UBi(1`QaUTY&y-X)dBBv_!qo^Q|vgBK%zueUC5NOE&=~~G~ zB$&|Ezvqx(?Azl;+Kj63OwyDpz3+NP+_O)f>hI2_-v-rwFhbFPP{XkomTeM0h<~uY5UR!%rV`5=rCP$aii9>o!s5I@eGUqvM!tCm3$1g&1Bf}VGkz} z^U@My(CBO&(fwsiX*0k1*{5#Dj!qcBYhWnw1@E?#%;uhWg@vZfa7kjUUy0UOr>v>M zlFayUT5KS#oh=nStvFd7#y@8T&tBWOZA8}FDxYipfp7xZzte=lfUxpN=q3a-8|gWp zGRXw#(4Fjt>TlJY8vy$k*M&a`mw%(^e=h98r>&M>U1>eMIES3!uEB`GErWI)Yo7pN zRwuHemzkei;z~HI%)q~0NHF&q5C-E+dUtpB%}t*ZCHjnP@i3zAc0p*ZhSzG`@%$#; zzIQg)Yba`smSBkt`Omer7R0MCWr_@94nx>@^NiNk{5;19QpLFXg0`Cr)M~#UU<|i# z4GqIAK-u1`J5~X)JMt0KRKNMWxd)Kfi61?iJD(kHPiR3b*;4iTE-L5(?Ux*bi3)Nc z6ELAzW|dI$XiaQTk6npc+oh z<}=qPL<#Ix=nqX%E)4EbP~45Rv7*K&7Cupz*v5a$)3AYV^~ow)0Mn#_$%5a$EJ{r+ z^*QXjMX6nS1v%@>_LGxSSUQ|PNZ_c%_66-(V$(79Q@jUhg=6;#2+m9?R;h+Cy=P|g z1b69)vMh3$iMlS!cm!BXkMxO#vX@#zEM_w+>fKrnLWLj1hNj+LO|6d=c2eX&`b`oJ z(S&fHWVs|(dgCQbQLt(As>(i{M~jCrr393LpJv&~WtR37ViD!&g>p$1rf5D!4 z$34Vn5)pJQkTPaoG=Z_b1@*6(2W>dI$+ zMj4U1Vvevy{O5Zxl>qz~?%r;(U3M!mnG-5;{tdzuPWZ>b_Ij5BC(PXJaopM%hPG)>*ntsT5ZdAh1 zxDK1w0TlGH^5;x3x)9{!)uSf867}2vF0H{e;r@$QXJ|XHndjz{w29}_Bzz{mF(-7* z(b?>=@P@1*Wqlg6p=Z`(Ui0TDDs3^>n4qF#TD7yr3c9gBUUcXI+z4GIJc#&GS_!2* zm_2VI-`vA*$x>qGm_~5uy#vX6E=yg7J^wChF8~a-L*Z)^iH`iyosp_DNEZ{|o|2=7 z3|D|`R#1#Uj)RxeRotw{(x+k`$PC<~?O$A}+D}Oh8!ro#nbq;vVraTM!C0NN%Ha0Z zMUhmf$A&_LJfB!uIOX4XXxTy0>o8d1i}3&Fup;C2i~d~G3qz_+)}m7C4J`xGgF8;1e4z^LISf9=^>9jl<2 zt|(u!gh{trEMVVe-DZ1Uz(<3gjt0+RpUqk0V?=Fuq}#9VtHN;}qK^%Fk%>X~kmBqY z8%4xhq#xepQ2^mN8WH~7>Tj@eO->@6I-IhQsV0x-ozelNYqFPU-bi=&!AKsvo?iP!C7GT$b=C-%)RlT zDJ%MgEt`n*%T-^)=Oor@FK%q+_PPL2|1&L`wfMkQ3Kc%LtG2D_eOaeC6kT5()T*&HXfqa@V%w<|IbS{o&GU$vGKIqKf48xfI?5jG( zU#TQRxN;_Uc#7@FNv2kk^)6tW3wRexPVw0IJ@`CH-M1@W6OH1-S#!o{)WzPDWZjE> zbN2YWi;k<+2E&;?F32?B&qY@zKRk%zBalxNGQ#$Qk=(1v04?}vk~E@D`u725&L!l{k0)~|arlC2n-%OlCoRaB&-bxJhMMNNlW*;C$@;w;P2R-fhJaLlJ87*)6Cxo&R(+8)b% z>epLf9l0GgFH%4E*N4tS0;ROWT^BSxv<1YvNFwv>bX*0@PK0KhtuQs4oOmuSt{J`} z@yB>Jj)VefCOksh@aagjegY5(kHs}=it};kG4fu!=Ey|uq38v7+kD^IuDnZgW(2>Y z^%Y4nty$o=P|V<}e*S6P6Cz2DqXIrxRZVve{(ATeIC;-f3UTP0*Cw7Dt@hy662UL+ z6LLPss%d@^Qu>L4y>29v<4Zy0v+=4V7+lFOwl)3PJMXp^7$YFkib?@e$~6*U$C^F0(;c=GvRbz#GA8d9ekYTQRTPw zu?n*6k|%U?IJ~$mrZTa3DSA%AeT{rR55Zz@#gX?3YOu0nv>>hb%sUzXU;LO0*`GZ? z6R5O2u`r*GMtzoCZsd_9s;Y0&*ge5oX)|q`|8-!OC-|cw#l}r%^MtykxBax~VxDFd zkfHVx>uAhYA0H{{Zoh8beiA)Zse1ew0~yGlK(e~!1^?<15~q=;80&Gwb7W}@`(Pw;C>P~>6uJrft|GpK4w|V1moA!L8otHjHzt5jDzaDrJ*DZLEybTehEj> znx7f*V^Bll~Pr8qTzVe@1lkTSRkX=ydpvTrQ*U zNtQaCK-YdAAr~z-w%N*m+Fo+*w)(_}?BGB^Uu_zzN7TimQM2RPAvyllIme{8^=@RL zuvt${N&)KEm;?<~-;mbA=0p}6hivkxD|L2%FDy8l=WJEQdd;b?S|hU`oKfbcYcn6e`K+JkaK z0@p0f`TD;CEcUx74q0XECLnk3J1Wg<#1kudHFsH|{S+-zwj%%+DLu;rC~k*>hg^T%I0sLwCevQ{5H?U(`6vuwY1Q`kooZ812jpcqfiXLdYH&kLJ zMhvkaUnJA>hQCbz)#$+p#H`%4BRMJImClpzob!7|tAfBZXA+Y9*3KI}*|E6ijN{`i z^Hd<$Bj@~N1I`1mCqfLTJ6*1zdG#_p@nV18&%LFtWG39_>8-Y_qd+C`l7+UgO4Qpl zQekT$FDhb>b0xL%LbzpD5~#T(;X8D@nhF-kz*fW4q~ub(=?n!sYJVcU9ox1=_37>@ zoJU%4yD($exd)N(5_GJ>XHaX2;hJ6VaUB+d zN*Y{M3fXqnmg0c`VaD0iL*7S{0%v(&*h3qg{xu>5g>1~i^++3dm}zcSuL8OZLDc^3 zSDv?2=+_9}iAkp^!H`z-^OjcFSC?Drq>SwA&yhad6X9?7;0<+U_RKIh3>l=i^q$;K zPtEscpKjfWDM>u!27#1I!ssF^{Z0V8jmnOSyCYH?F;fW zF~aXC6E?|h0o{%=M!p#Oqi6@F+qI3uLRvx3VE+FACLTNLVwqoutde<*!5uX{tVy(e zVrfi{Z0o+5K5WfZbFEmZ_91yGF_SAfFf)ZVCMRo}Ha15r%oH(@;I{CuC=lz1bGAii zH}7<@$E?H9)BAs6g+dO3tbFgBM*$L*TF2=T6t}`)FTcWt^@%ME~t-0^aZ! zeb1`9^4fPFd&!+QL&IEHJn0ev)ff*$$_XOvA*%~ek~UaT&LwbH8L+KgS@+M-2htVy z+0fUWn+|HQ-;CL<>|7_dp=;()1oiy&zD~(N?VsW9gI5zM8)ao^AAO%j3YNCbl}PHs z2Nt?6z$=^(_21(T0eI*t_%^z%s5BDwelF6*LJ+K199>be`Oy_SySP}HkwS*vf5{mx z&ajAfd&5Pbqi`Dtdk{zMjYrwo#SqDUg6QzZJ!Ft`^ZtX{ami?Rqq?1D1`va22CoOA zBv1m2f-fN}Ek{F0ExzmXe-<&P4W?%BU>PPNgxuZG ziP?w2$;q)iRR0p12Kqc~LztCeyxl`?p|HMg3&ukLu1!FO>A0t?si|pCbn(0COZGb( zcN-}qWCtW+d=^WVcvBkXt%ne)uupy5K#1rbb+1pdWcSg6 zfh-}TK(b65B}IzAJ{8Pw!^-ccbtjBJW625?a-%NZ;8l3Cr6n|I z;#{`h()ZS-x#jR;udtrhE>PWRq+lU5j(G`trBd7-Zq=IfIZLJ_DKrGcG{lE}?Xdj( z7CDRSncT%Ns``;(y*jq!<1bIDEmWoay47n+)!ZND5^Vo-a}JCA<|j5fw(|E}7eCnj z9`|5onZ%Ae`vfH4>LJS|Ms)EdynA0`leiA>YN3Z^PAD2tjEtkPUhsnaCKg-sR)Ir4 zhYXjiDJzm8Ym+d)_ccHG+x`-OdHC&H_2J?~(ZIYdirGTSd?o5G2118sIc>qRr|WuM$0^XVu*@; z5EiFRJb{m(n89MweO&RVl%2WZGu4{RjHmv$O?r$wROa|Az1sK{ePz6uBZ0#wP1JuQ zgmsd<7dI890vB#cjw0u$NdJ*!<(9SL3;$})x+U3Ev3AE$MbNL}ZWwA&s zd`W-%A>UAOhm;cb6e>wR!(JEVr`CFvweNCwi`&3I?tOq3nt25t(VrC3uR3d&WRKb` zu1o90VRPCmtbg3tz_#v9YBrYi!$A|abT$fWK(wC_e0o#$kp zQ$3>TTdoy@J}p?o`gOG2pFrfr@_?~^FIhiq9^?}dGeFc7B3)%#^~s1jNsFr|?~mI8 zDT|D%%BhVmTmsIPki-w z?u0}AMhGP~zTHQPf@)eR729g~BO+y}v<~G78!wB7{xiHxElWV+U&G^mPv@RY(NjO< zm>76NV#}YYN0Spj&Bhgd&GAnh%E+=cLiU?dhJqWIbw>Z&=f5!#>9OrNdY_j4GOM)K zV3FeeB}*9ivAFLY!fQc`jbB}!9t%I4+^$NGk#o(wD+}F^C?!W0lrMoiS4yP%=hqn3 z!^qVHclU+kH z+BUvNqdXY2ULpiRx_=#L10Ic%E1e1TQl)4?%QlVSGd#*dTG5yH*)KS?WtK^We3Pwp zA37{;hE6!Vzv2Z8zc|0KHun~aAjJO}a(r=cGsIEy_u(^{JW0#G!?eLN4f*y|1*}Wc z=_t*dW+024r6XaRe$fN)##tUb&WuBYSskfWIo{ zxtpM@+LnvqW&C(qCDNW#j> zU8Ej<#yed7l|UhTwQAOA7N2?SsMj=cA-T4o^4Zt0vnjJ?1JG-U26YNUcoc!d_27y# zmrpXNelsOXPsIK*otH=_wEE*g%wgzr-+=POdn-p>;X~s1p9Q5jkhB37v9Pwn;~fwX z3Lm4lvc;XoYDpygYSHD&uMRHV3T^qk;wUMtEra7sy=h1e@z|Nf#R5I@^^t}47aKTG zKLNi_5-%`8@6oTOD82?A9K@KwhN@-8esWQ1pW9Q-sU*N;1gDRDvUVV@u&xjJ0o+Yo`HLqbIw(aaZZMn%aEV->g+1tf_=Y0548SbkZNgp2WjF( z-|LwA?qLzFH_zM+sm`2bN{k8H3e~zI$?D<$@4#HYa@HCML#SH19p1lWi;ufR?;unM z05;f1&gsYDpdsn&>5a~$C%~94KJNM%i#A?xQIX}aO0z0Q8?^G1ttU)SO*tfEfblj(MCPf<#qebc342D=MWDj7t?$O6RJ~Y7NAQ&70gG?Lyw*mMT$5GDw)b z`#w(;N6E`yoW90LS^i<*dvok}%0XF%Q&0T9*xl%|}kS(oN>PrIJgh&Je26Ht8aMd;_?lvm2P%Ryg1AYYL*iHaKK| zNPHzDOf6p=M$)}WY)U95b?dEqzK%K*A((*O%XYhCP*+k#G-=ivUdhV!273}{aoMMbp_c%6rz zLW>By>9`fV?swxV#|l_u&A=J&mrDHD5*N!}HRsZAr;m44@b<}A1ugM7T3w6(mTFeNkutB& z_eg7L8THQ^Ine{JebfiFBSI99Xv;UKNU3Fll6(p17Nn#Zd_noNCYl?a@A%#^AAi}I zAkQGI$R%G;)@A7zBLG)MM07J2y`<2n6(uhF$*jL`e(E{SsF%Gf z-DR){1kPsa6<{A>b5}|U5)lzm7>Tyg?>m+#sS}L(qBvgJ|AV^LEYd?c%h({(;6t>P zS6Fu+tW}M_etP8d0k);~GP*{E;z5qoH{v%LKMTFDBG>OG=#}g|@^UF~T~`T~2W_os zpw;>&sMg5F)KA6TmV|IR!rz@XEQad=%BGtKpLTBKPToVABKPjY;~+Nq^S4Sh@y|2A zC*IOzp){Joyszr|z2C-1iwZ;e598Tuz*0xo{FFl-nJO0D^KQ&0piw{s{I-#2o~uYEE=w5{!q<+Uye_QYLe_U{srTXeXm4xpDW?&` zMac+fIw)$E;fU85OAgSF+iuyihY?1(Ub5}X(v5*PHDK7NaOqJ@fqdPDkg@`pS+$jhm# z{s3L0Q3-a6@+H)s^}6+^PVSw`Mit)mE9c}=@rjS4h#u_hBXTgr9XRZAh=$J(x6jPW z_EoC5~?Y zy!*RYt?)1C7#9)YHtE|J5AOE7A8V5AXjN9x*&nd3L}{wj+B)<4tZviqv9`wV`osu2 z#{)jvu$DxZaj@~hOH!xJwF-oB7MqwJ?!~-D9;XvidzFf^d-l^QhJ~xyLCDzSYpL{^ zq~>43p0q{(i*c4>c)j-=Sj<3=M-c+9s?{vAcK5?(cQo{3S}C@}a6*g|AP+)ie`k_v z9+m40y!XhKY+ZfP6FEiA)~tx+eeij}VA}rovkTG!b|c{;e{X6cIAp`j4A##rjl0~9 z`;$Pr$3kQyJVuif@TG1vuheyK1O;Jz+@#P)EMlD&?XYN_qR*3o6_F|_2yxXeWqiP+ z%0w+cUVaQ|%6A#(vhu4c+42%9xi$6n#__h0DA_GGLR|8~%yh(foy`@a(Ije`X6|Xz zf|^jeE;mImMEmiH;Xk#bQ4{eA5OA;&aE8Uj zb=>4$x#ZF01(0(U_NJf=?YL3daN@|bFCuWGPl5&$C0*Dpu?T1$f(ATs8IO+j^Gu}cwp#e zgj(z1hzZU|Pvlu+9PD6SGj6L^0@r=EcEEI>r0+2heu4`ieM1Ll~~ z@P1-~DtE>=HH@qJA)cx;I*Aqce@+=Y*pmkPtyLk?N?ra<<6-ncL2QiJ^Gu$Xib7(`WtoNw0#=K)ZF&yXr@#EOw`d8eEphZMs5 z6-{otGnLDKv1eVExgi@gDT)jl$kO7-(%%sg@)`LNJ|wZ{NGvWaSjlM1Z_fmUUDTiX z53S!%I5`J>W9fJf)Pt{`ErZs*j06I9qeMXSx3V@GK6^-5(ujUrBDafJEsjlmLHA?7 z#&L>em$J+f`;l zqHyv8-U&-qU30ch7^lu#3kRm0fZqecr)z}n;#6q?MqGT+5?n`N|1)9VP#Xmh_NDhF zD?cd*JpY@r&@zH8fk$1aYEIW3P>r_<@&b5+FG5koydMrfLD+W!C8314I8QQUA*k-m|0Mr)fKhiGg6B(+G@$ ziNNb|E>9J$%r)JL$fv1dOuTr(^K{%OlV%6XS9!pfnfghNu_T=T^6mTIzfM=voB5@9b#Q z9T#W`YCmnZ&K)RG%OA4>H;-5qI>6of?R(Dsy+rSf+Y;f3D5ES9&b)2Ws-@6!M_| zQ`EJGGui*)HL6Ks5|YCxqfEStqJ(mY%IKh&-%B|*TX-epFgh^Dl5$wm@RsxD5T#Jg zl0#EYo2-`EWKq&g+w6TDf7kU~&-J~&*K@k=@BO*&`}5E9Jpbbqt(V)7kdPtpHeq!zy1!D0R-KQ^P2cth38q4?OkOv|uO~YuLanc3gthTs#5!x%7;Eyi#NO#v{qN-@gaH z&ph-`=_5ThXf*+^ix?|(Y}@2d?|DQCv(!(Ov}@uEg(%Z&4?3%)v#ury%&0FP6bjFa z=-+tk?LI>iHfq=qv)Q{9Vlk1u?|ixq$Tzzhjz&@?v;x>pTZZME8vovzxYH`}>*aXN z&~~7%!Kd4C3h%7=+QljGlEPJ*s;q1HH)I5AV~IlWfSqY-gYtP3S~n+_7Cq$;wX?JjQ#t92d!v(hHgfLU1nze5^;Yti?=E8@ybyh>eqUc4_KpgRh?hF zzb>y^S%oxxaK`Hp@mXG1k)~1x!ANrlF zk+Wg4#Nr8!5Ur%O5# zcxPny`b}?0ilCo)k%W zM@ii*Q^XJPU(amY!Ecxw{+Vw>JEtZJy&uwkLT}hDl6{P6cK08O!fQq0m~wc5=*ohC z%^Kr58#@(?rh#6xW%k=7`MH_WKKLKTXB=ik5z8c27H!p1()q~i4seI<6A ziV+M|RBt=Gdocqn9x;~Suq>2lfst6o+CeXmQ$T1-etFT1Gmfgmy1d*k+mcNK{0RGf zr1qNIS97U0 z8W)p5EG@Pm@s>}{)q7>T<0lKwlb*H9>!px{X74g+)tYY5exAcFoowHM(MN5Y$+`2b zNo_om%ZkL9i*t7c0DPwmB$)OJ_IjNakzGPN@z9+bsofd;dkZsa^VVwDwJkjPocib8 zb!-iO<2}uqsdN;EGCM&*zQsCMXvoq9;wlW2&JocJpU?4#Q;8@K{LZUgK-`8HB#!4s z<$-IsQ*xIuZ*3Rh@I`{r4*(~7c4xlDBjqnA^88nLsmt?2>3}Fon-7WQ*B=UFOUy5& zFTg>YAHLaG{kuYUgE;~DS#mdF;*kY+Dxes1Sm--#zdcT4#blQ*HiT5iI8Il&zNg1HXmdWU4*0WvmO+u#zO15bnGfiFatR*w z$RoyYhMytNiEWx#wpQTmE)6bmDyw`EV4WR&GDlg$u*JnGA0z+H1(&Jj_RBpNY5TJ= zbQe=IS#T>e(Z@vElm_G-=V}KyMR^NkZ_n2C3K`6YpqsZ39BZh*A96~Q*tyIBf*t5f zl45g{WNrq!qI^rM&Q`@F|2jF7^r+4~>Ge54dv)}SyE)$W;4#g|ZLp*ITVqwxHy5xV z5ZJ=j(&B7vq~cQ@pu<^WbQt_@2wlcQu_O~;_2Ms)QKw9F^0QobRHCT*ha^A7Z-6?` z1+;6?%~2M&_PwLa_S$>;9uqf+z0ih_Q>BE=Ud(*@V#Q$a<{lET9!8shrY(8feJ2AV zbVBJy4L0@ib4OKs5QJLTmam||0}8zmL-DI4GX7=yb6P>6pJTlHF|)o^Xd~|==M9sQ z%x#mA3-g||y5T5jJzMQhLP0yg z&>#01%RmHzmtGKK0r`j^6iGv4Va=}QJNriN^$A=RPiG)hV(y|8<9E#W^^;uJ zmfkbA#(VCja+O@t-{A`PyZ&~dn}q*^`xt-F`Iic;i_Fty!l#SUmy z;Un{7mODHdp$1Z?O3cL} zSp4|Kp$T;5|EKZhMqt_Q;n3aBA#yR%5o_=A)n6AszCr9<@Y;7L!-yu9FgNjCgkSw? zGk}P#@fH>bOa=|@whFL4lCazs0OvOW8b44V5J)77Y%(1I1{)U1BnFBXDupmTFp9to N$kxi Date: Sun, 12 Jul 2026 19:22:16 -0700 Subject: [PATCH 070/101] docs: document Cosmos3-Super AutoRound world-model support (#5062) Signed-off-by: yiliu30 --- docs/user_guide/quantization/autoround.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/quantization/autoround.md b/docs/user_guide/quantization/autoround.md index 3afc8b062..bafadec9c 100644 --- a/docs/user_guide/quantization/autoround.md +++ b/docs/user_guide/quantization/autoround.md @@ -3,7 +3,7 @@ ## Overview [AutoRound](https://github.com/intel/auto-round) produces pre-quantized -checkpoints for LLMs, VLMs, and diffusion models. vLLM-Omni reads the +checkpoints for LLMs, VLMs, diffusion models, and world models. vLLM-Omni reads the checkpoint's `config.json` and auto-detects `quantization_config.quant_method = "auto-round"`. @@ -36,6 +36,13 @@ guide. AutoRound is Intel-supported. | Wan2.2-T2V | `Intel/Wan2.2-T2V-A14B-Diffusers-int4-AutoRound` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend | | Wan2.2-TI2V | `Intel/Wan2.2-TI2V-5B-Diffusers-int4-AutoRound` | Diffusion transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend | +### World Model (Cosmos3) + +| Model | Checkpoint | Scope | Scheme | Backend | +|-------|------------|-------|--------|---------| +| Cosmos3-Nano | `Intel/Cosmos3-Nano-int4-AutoRound` | World-model transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend | +| Cosmos3-Super | `Intel/Cosmos3-Super-int4-AutoRound` | World-model transformer | W4A16 | GPTQ-Marlin or Intel-supported AutoRound backend | + ### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS) | Model | Checkpoint | Scope | Scheme | Backend | From 8e14f69d96d461edd7fbac0781cc1e81840ed1af Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:24:17 +0800 Subject: [PATCH 071/101] docs: update WeChat QR code (#5061) Signed-off-by: David Chen <530634352@qq.com> --- docs/assets/WeChat.jpg | Bin 272842 -> 183022 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index 5913183e937f6a5b5bc5b9e562364c4fabaa7023..cb7300de29fdfe8466ee1d689129218533e9bd15 100644 GIT binary patch literal 183022 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 E3uV-SL;wH) literal 272842 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

&zF*J^WTW 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З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 z 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 From 3f4abedbda281302b28f7885c270fce3e342c924 Mon Sep 17 00:00:00 2001 From: Zheng Wengang Date: Mon, 13 Jul 2026 10:50:51 +0800 Subject: [PATCH 072/101] [Perf] Ming-TTS Stage-0 flow-head speedup: RoPE/QKV fusion, opt-in torch.compile, PIECEWISE cudagraph (#4942) --- vllm_omni/deploy/ming_tts_moe.yaml | 4 +- .../model_executor/models/common/ming/dit.py | 61 ++++++++++++++++--- .../models/ming_tts/ming_tts_llm.py | 22 +++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/vllm_omni/deploy/ming_tts_moe.yaml b/vllm_omni/deploy/ming_tts_moe.yaml index 48c5e9aa0..76425712a 100644 --- a/vllm_omni/deploy/ming_tts_moe.yaml +++ b/vllm_omni/deploy/ming_tts_moe.yaml @@ -31,7 +31,9 @@ stages: max_num_seqs: 1 tensor_parallel_size: 1 gpu_memory_utilization: 0.60 - enforce_eager: true + enforce_eager: false + compilation_config: + cudagraph_mode: PIECEWISE enable_prefix_caching: false async_scheduling: false max_num_batched_tokens: 8192 diff --git a/vllm_omni/model_executor/models/common/ming/dit.py b/vllm_omni/model_executor/models/common/ming/dit.py index 3b4ab1122..56e65f47f 100644 --- a/vllm_omni/model_executor/models/common/ming/dit.py +++ b/vllm_omni/model_executor/models/common/ming/dit.py @@ -6,6 +6,19 @@ from torch import nn from x_transformers.x_transformers import apply_rotary_pos_emb +def _apply_rope_cached(t, cos, sin): + """Bit-exact RoPE for ``scale==1`` / interleaved-half. + Uses precomputed ``cos``/``sin`` per seq_len to skip per-step trig and cat.""" + rot_dim = cos.shape[-1] + tr = t[..., :rot_dim] + x = tr.reshape(*tr.shape[:-1], -1, 2) + rot_half = torch.stack((-x[..., 1], x[..., 0]), dim=-1).reshape_as(tr) + out = tr * cos + rot_half * sin + if rot_dim < t.shape[-1]: # partial rotary (unused by ming heads); keep general + out = torch.cat((out, t[..., rot_dim:]), dim=-1) + return out.type(t.dtype) + + class RMSNorm(nn.Module): def __init__(self, dim, eps=1e-6): super().__init__() @@ -66,12 +79,33 @@ class Attention(nn.Module): self.to_out.append(nn.Dropout(dropout)) self.pe_attn_head = pe_attn_head self.attn_mask_enabled = attn_mask_enabled + # cos/sin cache keyed by seq_len (freqs are deterministic in seq_len). + self._rope_cache: dict[int, tuple[torch.Tensor, torch.Tensor]] = {} + # TODO: materialize fused weights eagerly outside any torch.compile + # scope (e.g. post-weight-load); + self._qkv_w: torch.Tensor | None = None + self._qkv_b: torch.Tensor | None = None + + def _qkv(self, x): + if self._qkv_w is None: + self._qkv_w = torch.cat([self.to_q.weight, self.to_k.weight, self.to_v.weight], dim=0) + if self.to_q.bias is not None: + self._qkv_b = torch.cat([self.to_q.bias, self.to_k.bias, self.to_v.bias], dim=0) + qkv = F.linear(x, self._qkv_w, self._qkv_b) + return qkv.split(self.inner_dim, dim=-1) + + def _rope_cos_sin(self, freqs): + seq_len = freqs.shape[-2] + cached = self._rope_cache.get(seq_len) + if cached is None: + f = freqs.unsqueeze(1) if freqs.ndim == 3 else freqs # b n d -> b 1 n d for 4D q/k + cached = (f.cos(), f.sin()) + self._rope_cache[seq_len] = cached + return cached 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) + query, key, value = self._qkv(x) inner_dim = key.shape[-1] head_dim = inner_dim // self.heads @@ -86,14 +120,21 @@ class Attention(nn.Module): 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) + scale_is_one = xpos_scale is None or (not torch.is_tensor(xpos_scale) and xpos_scale == 1.0) + if scale_is_one and self.pe_attn_head is None: + # Fast path: cached, cat-free RoPE (bit-exact for scale==1, full rotary). + cos, sin = self._rope_cos_sin(freqs) + query = _apply_rope_cached(query, cos, sin) + key = _apply_rope_cached(key, cos, sin) else: - query = apply_rotary_pos_emb(query, freqs, q_xpos_scale) - key = apply_rotary_pos_emb(key, freqs, k_xpos_scale) + 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) 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 index ae4d61f68..39ddd7b9f 100644 --- a/vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py +++ b/vllm_omni/model_executor/models/ming_tts/ming_tts_llm.py @@ -326,6 +326,28 @@ class MingLLMModel(nn.Module): try: from .fm.cfm_cudagraph import CFMGraphExecutor, CFMSampler + # TODO(perf): + # (1) compile per-block (dit_model.blocks) instead of the + # whole forward for tighter fusion / fewer graph breaks; + # (2) move the compile + a synthetic warmup forward to a post-weight-load hook + dit_model = self.flowloss.cfm.model + if not getattr(dit_model, "_ming_compiled", False): + try: + # Scoped options (avoid mutating global inductor config). + dit_model.forward = torch.compile( + dit_model.forward, + fullgraph=False, + dynamic=False, + options={ + "triton.cudagraphs": False, + "triton.cudagraph_trees": False, + }, + ) + dit_model._ming_compiled = True + logger.info("Ming CFM DiT torch.compile enabled (inductor cudagraphs off).") + except Exception as exc: + logger.warning("Ming CFM torch.compile failed (%s); using uncompiled DiT.", exc) + sampler = CFMSampler(self.flowloss.cfm.model, steps=_CFM_STEPS) self._cfm_graph = CFMGraphExecutor( sampler, From 161b005cfcfa1c17262b6ee4e2ccf546d1e0c50b Mon Sep 17 00:00:00 2001 From: "Chendi.Xue" Date: Mon, 13 Jul 2026 02:34:27 -0500 Subject: [PATCH 073/101] [XPU][CI] Fix xpu after rebasing for 0.25.0 (#5066) Signed-off-by: Chendi Xue --- .buildkite/pipeline-intel.yaml | 2 +- .../scripts/hardware_ci/run-xpu-test.sh | 25 +++++++++++++++++-- docker/Dockerfile.xpu | 2 +- tests/platforms/test_cuda_ir_op_priority.py | 11 +++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.buildkite/pipeline-intel.yaml b/.buildkite/pipeline-intel.yaml index ff7302ad2..d3a2655bf 100644 --- a/.buildkite/pipeline-intel.yaml +++ b/.buildkite/pipeline-intel.yaml @@ -10,7 +10,7 @@ steps: DOCKER_BUILDKIT: "1" # Buildkite will automatically replace this with the actual commit hash VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}" - VLLM_VERSION: "v0.24.0" + VLLM_VERSION: "v0.25.0" priority: 100 timeout_in_minutes: 60 soft_fail: false diff --git a/.buildkite/scripts/hardware_ci/run-xpu-test.sh b/.buildkite/scripts/hardware_ci/run-xpu-test.sh index fa4f00e29..36860522b 100755 --- a/.buildkite/scripts/hardware_ci/run-xpu-test.sh +++ b/.buildkite/scripts/hardware_ci/run-xpu-test.sh @@ -13,12 +13,33 @@ container_name="xpu_${BUILDKITE_COMMIT}_$( )" cd "${omni_source_dir}" + +# The XPU base image is ~37GB; the default gzip layer exporter is single-threaded +# and takes ~16min to compress it. zstd is multi-threaded (uses all cores) at a +# similar ratio; set EXPORT_COMPRESSION=uncompressed to skip compression entirely +# for local-only images. This requires the containerd image store (buildx docker +# driver), which is the default here. +EXPORT_COMPRESSION="${EXPORT_COMPRESSION:-zstd}" +if [ "${EXPORT_COMPRESSION}" = "uncompressed" ]; then + export_args=(--output "type=image,name={{IMAGE}},compression=uncompressed") +else + export_args=(--output "type=image,name={{IMAGE}},compression=${EXPORT_COMPRESSION},compression-level=3,force-compression=true") +fi + +docker_build() { + # $1 = image name; remaining args passed through to docker build. + local image="$1" + shift + local out=("${export_args[@]/'{{IMAGE}}'/${image}}") + docker build "${out[@]}" "$@" -f docker/Dockerfile.xpu . +} + if [ -z "$(docker images -q "${base_image_name}")" ]; then - docker build --target vllm-base -t "${base_image_name}" --build-arg "VLLM_VERSION=${VLLM_VERSION}" -f docker/Dockerfile.xpu . + docker_build "${base_image_name}" --target vllm-base --build-arg "VLLM_VERSION=${VLLM_VERSION}" fi # Try building the docker image -docker build --build-arg "VLLM_BASE=${base_image_name}" --build-arg "VLLM_VERSION=${VLLM_VERSION}" -t "${image_name}" -f docker/Dockerfile.xpu . +docker_build "${image_name}" --build-arg "VLLM_BASE=${base_image_name}" --build-arg "VLLM_VERSION=${VLLM_VERSION}" # Setup cleanup remove_docker_container() { diff --git a/docker/Dockerfile.xpu b/docker/Dockerfile.xpu index 52069f344..b70dd274e 100644 --- a/docker/Dockerfile.xpu +++ b/docker/Dockerfile.xpu @@ -88,7 +88,7 @@ ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL} ENV UV_INDEX_STRATEGY="unsafe-best-match" ENV UV_LINK_MODE="copy" -ARG VLLM_VERSION=v0.24.0 +ARG VLLM_VERSION=v0.25.0 RUN git clone -b ${VLLM_VERSION} https://github.com/vllm-project/vllm WORKDIR /workspace/vllm diff --git a/tests/platforms/test_cuda_ir_op_priority.py b/tests/platforms/test_cuda_ir_op_priority.py index c27afb7ee..8542ea63d 100644 --- a/tests/platforms/test_cuda_ir_op_priority.py +++ b/tests/platforms/test_cuda_ir_op_priority.py @@ -3,8 +3,17 @@ import pytest from vllm.config import CompilationConfig, CompilationMode, DeviceConfig, VllmConfig +from vllm.platforms import current_platform -from vllm_omni.platforms.cuda.platform import CudaOmniPlatform +# Importing CudaOmniPlatform pulls in vllm.platforms.cuda, which imports the +# CUDA-only ``vllm._C_stable_libtorch`` extension at module top level. That +# extension is absent on non-CUDA builds (e.g. XPU), so skip the whole module +# there before the import can crash collection. ``is_cuda()`` resolves the +# platform without importing cuda.py. +if not current_platform.is_cuda(): + pytest.skip("CUDA-only IR op priority tests", allow_module_level=True) + +from vllm_omni.platforms.cuda.platform import CudaOmniPlatform # noqa: E402 pytestmark = [pytest.mark.core_model, pytest.mark.cpu] From 98229b08ba36fb71cbb5c45a466496b798b43fa4 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:34:46 +0800 Subject: [PATCH 074/101] [Bugfix] Pass --trust-remote-code via HunyuanImage3 DFX serve_args (#5006) Signed-off-by: wangyu <410167048@qq.com> --- tests/dfx/perf/tests/test_hunyuan_image3_it2i.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json index 200251ba6..fa955c763 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json +++ b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json @@ -7,6 +7,7 @@ "server_params": { "model": "tencent/HunyuanImage-3.0-Instruct", "serve_args": { + "trust-remote-code": true, "deploy-config-inline": { "type": "yaml", "content": { From 66b9ad287abe087a59066bf0eeacb29bc8c0eda6 Mon Sep 17 00:00:00 2001 From: Rahul Steiger Date: Mon, 13 Jul 2026 11:48:53 +0200 Subject: [PATCH 075/101] Extend Cosmos3 CPU offloading to component and layerwise paths (#4695) --- .../diffusion/cpu_offload_diffusion.md | 44 +- docs/user_guide/diffusion_features.md | 4 +- .../models/cosmos3/test_cosmos3_pipeline.py | 10 + .../cosmos3/test_cosmos3_transformer.py | 104 ++++- .../offloader/test_module_collector.py | 66 +++ .../offloader/test_sequential_backend.py | 37 +- .../model_loader/diffusers_loader.py | 16 +- .../models/cosmos3/pipeline_cosmos3.py | 44 +- .../models/cosmos3/transformer_cosmos3.py | 424 ++++++++++++------ .../diffusion/offloader/module_collector.py | 32 ++ .../diffusion/offloader/sequential_backend.py | 32 ++ 11 files changed, 653 insertions(+), 160 deletions(-) diff --git a/docs/user_guide/diffusion/cpu_offload_diffusion.md b/docs/user_guide/diffusion/cpu_offload_diffusion.md index f827e79b3..765fb9535 100644 --- a/docs/user_guide/diffusion/cpu_offload_diffusion.md +++ b/docs/user_guide/diffusion/cpu_offload_diffusion.md @@ -82,6 +82,33 @@ mutual exclusion: when one group runs, the other moves to CPU. - Support single GPU only for now +### Component offloading for split models (e.g. Cosmos3) + +Some models split their transformer into mutually-exclusive *components* that run +in different phases of a single forward pass rather than as separate pipeline +components -- e.g. Cosmos3's understanding (reasoner) component runs once per +generation while the generation (generator) component runs every denoising step. +Such models have no separate text encoder to swap against, so the transformer +owns a small model-local offload path and wraps each phase with +`with self._offload_context(name):` + +```python +class Cosmos3VFMTransformer(nn.Module): + def forward(self, ...): + with self._offload_context("reasoner"): + ... # understanding pass, runs once + with self._offload_context("generator"): + ... # denoising pass, runs every step +``` + +Model-level offloading then keeps exactly one component GPU-resident at a time +(the other on CPU), reusing the same `SequentialOffloadHook` `.to()` movers. The +pipeline opts in by exposing `enable_omni_model_cpu_offload` (which drives the +transformer's `enable_model_cpu_offload` and pins the VAE). Layerwise offloading +works for these models too -- each component declares its own block container via +`_layerwise_offload_blocks_attrs`. + + ## Layerwise (Blockwise) Offloading ### How It Works @@ -145,9 +172,9 @@ class Flux2Transformer2DModel(nn.Module): ``` ### Limitations -- Cold start latency increases because of - 1) components are loaded to CPU first at the very first during initialization, - 2) weight consolidation and pinning +- Cold start latency increases because offloaded components must be moved to CPU + during setup; layerwise offload may add extra weight consolidation and pinning + work. - Performance depends on compute cost and H2D bandwidth as well - Support single GPU only for now @@ -178,11 +205,18 @@ Both strategies use vLLM-Omni's hook registry system (`HookRegistry` and `ModelH ``` OffloadBackend (base class) -├── ModelLevelOffloadBackend → uses SequentialOffloadHook +├── ModelLevelOffloadBackend → uses SequentialOffloadHook (.to() swap) +│ (delegates to a pipeline's enable_omni_model_cpu_offload +│ for split models like Cosmos3) └── LayerWiseOffloadBackend → uses LayerwiseOffloadHook ``` -Factory function `get_offload_backend()` selects the appropriate backend based on configuration. +Factory function `get_offload_backend()` selects the appropriate backend based on +configuration. + +For split models, `ModelLevelOffloadBackend.enable()` detects a pipeline's +`enable_omni_model_cpu_offload` hook and delegates to it; Cosmos3 then swaps its +reasoner/generator components inside the model forward pass. ## Supported Models diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 37bc99aa0..d3ed3bd67 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -105,7 +105,7 @@ The following tables show which models support each feature: > Notes: -> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. +> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload_diffusion.md). > 2. The **💾Quantization** column is collapsed for readability. See [Quantization Overview](quantization/overview.md) for per-method and per-model support details. ### ImageGen @@ -144,7 +144,7 @@ The following tables show which models support each feature: > Notes: > 1. Nextstep_1(T2I) does not support cache acceleration methods such as TeaCache or Cache-DiT. > 2. `Tongyi-MAI/Z-Image-Turbo` and `SII-GAIR/daVinci-MagiHuman-Base-1080p` are distilled models with minimal NFEs; CFG-Parallel is not necessary. -> 3. Cosmos3 T2I uses `Cosmos3OmniDiffusersPipeline` with `modalities=["image"]`. Model-level CPU offload is not supported; use layerwise offload. +> 3. Cosmos3 T2I uses `Cosmos3OmniDiffusersPipeline` with `modalities=["image"]`. Model-level CPU offload swaps the nested UND reasoner and GEN generator pathways; layerwise offload remains available for blockwise GEN/UND offload. > 4. Krea 2 currently supports single-GPU inference plus LoRA, Cache-DiT, HSDP, CPU/layerwise offload, and VAE-patch-parallel (decode). TP/SP/CFG-Parallel are not yet wired. The few-step distilled (Turbo) checkpoint uses `is_distilled=true` (fixed timestep shift `mu=1.15`); generate at 2048x2048 by default with `num_inference_steps≈8` and `guidance_scale=0`. The Raw checkpoint uses 1024x1024, `num_inference_steps=28`, and `guidance_scale=4.5`. ### VideoGen diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index 77215f79b..f0159f82e 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -20,6 +20,16 @@ from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] +def test_pipeline_declares_layerwise_offload_components() -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline + + assert Cosmos3OmniDiffusersPipeline._dit_modules == ["transformer.language_model", "transformer"] + assert Cosmos3OmniDiffusersPipeline._encoder_modules == [] + assert Cosmos3OmniDiffusersPipeline._vae_modules == ["vae"] + assert Cosmos3OmniDiffusersPipeline._resident_modules == [] + assert hasattr(Cosmos3OmniDiffusersPipeline, "enable_omni_model_cpu_offload") + + class StubScheduler: def __init__( self, diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py index 70598c952..13d1c399b 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py @@ -10,9 +10,19 @@ import pytest import torch from torch import nn +from vllm_omni.platforms import current_omni_platform + pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] +@pytest.fixture +def accelerator_device() -> torch.device: + """Provide an accelerator device, skipping when none is available.""" + if current_omni_platform.get_device_count() == 0: + pytest.skip("Accelerator required for this test") + return current_omni_platform.get_torch_device(0) + + def _tiny_cosmos3_config(**overrides): config = { "hidden_size": 8, @@ -88,7 +98,7 @@ def test_validate_supported_config_rejects_unsupported_flags(key: str, value) -> def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None: - from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3LanguageModel, Cosmos3VFMTransformer model = object.__new__(Cosmos3VFMTransformer) nn.Module.__init__(model) @@ -104,6 +114,7 @@ def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None: ] assert matched == ["language_model.layers.0", "language_model.layers.1", "gen_layers.0"] assert Cosmos3VFMTransformer._layerwise_offload_blocks_attrs == ["gen_layers"] + assert Cosmos3LanguageModel._layerwise_offload_blocks_attrs == ["layers"] assert Cosmos3VFMTransformer._repeated_blocks == ["Cosmos3GenDecoderLayer"] model.latent_patch_size = 2 @@ -131,6 +142,97 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No assert tuple(output.shape) == (1, 2, 1, 2, 2) +def test_model_cpu_offload_swaps_back_to_generator(monkeypatch: pytest.MonkeyPatch) -> None: + from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 + + monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None)) + + class ToyReasonerLayer(nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = nn.Parameter(torch.ones(1)) + + def forward(self, hidden: torch.Tensor, freqs): + del freqs + kv = hidden.new_zeros(hidden.shape[0], hidden.shape[1], 1, 1) + return hidden + self.weight.to(hidden.dtype), kv, kv + + class ToyGeneratorLayer(nn.Module): + def __init__(self) -> None: + super().__init__() + self.weight = nn.Parameter(torch.ones(1)) + + def forward(self, hidden: torch.Tensor, **kwargs): + del kwargs + return hidden + self.weight.to(hidden.dtype) + + model = transformer_cosmos3.Cosmos3VFMTransformer( + SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32) + ) + model.language_model.layers = nn.ModuleList([ToyReasonerLayer()]) + model.gen_layers = nn.ModuleList([ToyGeneratorLayer()]) + model.enable_model_cpu_offload(device=torch.device("cpu"), pin_memory=False) + + assert model._model_cpu_offload_enabled + assert set(model._model_cpu_offload_components()) == {"reasoner", "generator"} + assert model.device == torch.device("cpu") + + output = model( + hidden_states=torch.zeros(1, 2, 1, 2, 2), + timestep=torch.tensor([1.0]), + text_ids=torch.tensor([[1, 2]], dtype=torch.long), + text_mask=torch.ones(1, 2, dtype=torch.long), + video_shape=(1, 2, 2), + fps=24.0, + ) + + assert tuple(output.shape) == (1, 2, 1, 2, 2) + # The reasoner runs once then the generator component stays resident for GEN. + assert model._active_model_cpu_offload_component == "generator" + + model.disable_model_cpu_offload() + assert not model._model_cpu_offload_enabled + + +def test_model_cpu_offload_moves_reasoner_and_generator_between_cpu_and_device( + monkeypatch: pytest.MonkeyPatch, accelerator_device: torch.device +) -> None: + from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 + + monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None)) + + model = transformer_cosmos3.Cosmos3VFMTransformer( + SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32) + ) + model.language_model.layers = nn.ModuleList([nn.Linear(2, 2)]) + model.gen_layers = nn.ModuleList([nn.Linear(2, 2)]) + model.to(accelerator_device) + + reasoner_param = model.language_model.layers[0].weight + generator_param = model.gen_layers[0].weight + + model.enable_model_cpu_offload(device=accelerator_device, pin_memory=False) + + assert model._model_cpu_offload_enabled + # On enable, every group is parked on CPU until a phase activates it. + assert reasoner_param.device.type == "cpu" + assert generator_param.device.type == "cpu" + + model._activate_model_cpu_offload_component("reasoner") + assert reasoner_param.device == accelerator_device + assert generator_param.device.type == "cpu" + + model._activate_model_cpu_offload_component("generator") + assert reasoner_param.device.type == "cpu" + assert generator_param.device == accelerator_device + + model.disable_model_cpu_offload() + assert not model._model_cpu_offload_enabled + # Disable restores both components onto the device. + assert reasoner_param.device == accelerator_device + assert generator_param.device == accelerator_device + + def test_forward_accepts_transfer_control_latents(monkeypatch: pytest.MonkeyPatch) -> None: from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 diff --git a/tests/diffusion/offloader/test_module_collector.py b/tests/diffusion/offloader/test_module_collector.py index bc7fd97d1..e97c1953a 100644 --- a/tests/diffusion/offloader/test_module_collector.py +++ b/tests/diffusion/offloader/test_module_collector.py @@ -149,6 +149,40 @@ class MultiVaePipeline(nn.Module, SupportsComponentDiscovery): self.audio_vae = nn.Linear(10, 10) +class SiblingDiTsPipeline(nn.Module, SupportsComponentDiscovery): + """Two independent DiTs (e.g. a MoE transformer pair).""" + + _dit_modules: ClassVar[list[str]] = ["transformer", "transformer_2"] + _encoder_modules: ClassVar[list[str]] = [] + _vae_modules: ClassVar[list[str]] = ["vae"] + + def __init__(self): + super().__init__() + self.transformer = nn.Linear(10, 10) + self.transformer_2 = nn.Linear(10, 10) + self.vae = nn.Linear(10, 10) + + +class NestedDiTPipeline(nn.Module, SupportsComponentDiscovery): + """Cosmos3-like: a DiT plus one of its own submodules listed as a DiT. + + The nested entry exists so offloading can treat the inner stack as an + independent ring; the outer DiT's _hsdp_shard_conditions still own all + nested blocks for sharding. Order is nested-first, matching Cosmos3. + """ + + _dit_modules: ClassVar[list[str]] = ["transformer.language_model", "transformer"] + _encoder_modules: ClassVar[list[str]] = [] + _vae_modules: ClassVar[list[str]] = ["vae"] + + def __init__(self): + super().__init__() + self.transformer = nn.Module() + self.transformer.language_model = nn.Linear(10, 10) + self.transformer.gen_layers = nn.Linear(10, 10) + self.vae = nn.Linear(10, 10) + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -238,3 +272,35 @@ class TestProtocolDiscovery: assert len(result.vaes) == 2 assert result.vaes[0] is pipeline.vae assert result.vaes[1] is pipeline.audio_vae + + +class TestOutermostDits: + """PipelineModules.outermost_dits() drops DiTs nested inside another DiT. + + HSDP shards each returned DiT via its own _hsdp_shard_conditions, so a DiT + nested inside another discovered DiT must be excluded: it is already covered + by its ancestor's conditions and may not declare conditions of its own. + """ + + def test_keeps_independent_sibling_dits(self): + result = ModuleDiscovery.discover(SiblingDiTsPipeline()) + + names, modules = result.outermost_dits() + + assert names == ["transformer", "transformer_2"] + assert [id(m) for m in modules] == [id(m) for m in result.dits] + + def test_drops_nested_dit_keeping_only_ancestor(self): + pipeline = NestedDiTPipeline() + result = ModuleDiscovery.discover(pipeline) + + # Discovery sees both, nested-first (matches Cosmos3's _dit_modules order). + assert result.dit_names == ["transformer.language_model", "transformer"] + + names, modules = result.outermost_dits() + + # Only the ancestor survives; sharding the nested condition-less stack + # again would double-wrap its blocks and raise in apply_hsdp_to_model. + assert names == ["transformer"] + assert len(modules) == 1 + assert modules[0] is pipeline.transformer diff --git a/tests/diffusion/offloader/test_sequential_backend.py b/tests/diffusion/offloader/test_sequential_backend.py index 2539cc068..dc74248e5 100644 --- a/tests/diffusion/offloader/test_sequential_backend.py +++ b/tests/diffusion/offloader/test_sequential_backend.py @@ -7,7 +7,8 @@ import pytest import torch from torch import nn -from vllm_omni.diffusion.offloader.sequential_backend import SequentialOffloadHook +from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy +from vllm_omni.diffusion.offloader.sequential_backend import ModelLevelOffloadBackend, SequentialOffloadHook from vllm_omni.platforms import current_omni_platform pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] @@ -41,6 +42,40 @@ def _track_pin_memory_calls(): return tracker, mock +def test_model_level_backend_delegates_to_custom_pipeline_offload() -> None: + class CustomPipeline(nn.Module): + def __init__(self) -> None: + super().__init__() + self.enable_args = None + self.disable_called = False + + def enable_omni_model_cpu_offload(self, **kwargs) -> None: + self.enable_args = kwargs + + def disable_omni_model_cpu_offload(self) -> None: + self.disable_called = True + + pipeline = CustomPipeline() + backend = ModelLevelOffloadBackend( + OffloadConfig(strategy=OffloadStrategy.MODEL_LEVEL, pin_cpu_memory=False), + torch.device("cpu"), + ) + + backend.enable(pipeline) + + assert backend.enabled is True + assert pipeline.enable_args == { + "device": torch.device("cpu"), + "pin_memory": False, + "use_hsdp": False, + } + + backend.disable() + + assert backend.enabled is False + assert pipeline.disable_called is True + + class TestMoveParamsPinMemory: def test_dtensor_skips_pin_memory(self, accelerator_device, monkeypatch: pytest.MonkeyPatch): """DTensor should skip pin_memory to avoid RuntimeError.""" diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index d8b9c90c5..e1aa0fd1c 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -636,6 +636,14 @@ class DiffusersPipelineLoader: # where the transformer lives at "pipe.transformer"). discovered_modules = ModuleDiscovery.discover(model) + # Shard only the outermost DiTs. A pipeline may list a DiT and one of its + # submodules as separate DiTs (e.g. Cosmos3's transformer and the nested + # transformer.language_model) for offload's independent rings; for HSDP an + # inner DiT is already covered by its ancestor's _hsdp_shard_conditions, so + # sharding it again would double-wrap blocks and require the inner stack to + # declare its own conditions. + outer_dit_names, outer_dits = discovered_modules.outermost_dits() + # Online FP8 quantization (Fp8OnlineLinearMethod) leaves layer weights # as non-contiguous transpose views (qweight.t()) so the Cutlass kernel # gets a column-major B. FSDP2 fully_shard rejects non-contiguous params. @@ -647,14 +655,14 @@ class DiffusersPipelineLoader: prepare_fp8_layers_for_fsdp, ) - for trans in discovered_modules.dits: + for trans in outer_dits: prepare_fp8_layers_for_fsdp(trans) - if not discovered_modules.dits: + if not outer_dits: raise ValueError("No DiT modules discovered for HSDP sharding") - # Apply HSDP sharding to all discovered DiT transformers - for name, trans in zip(discovered_modules.dit_names, discovered_modules.dits): + # Apply HSDP sharding to each outermost DiT transformer + for name, trans in zip(outer_dit_names, outer_dits): logger.debug("Applying HSDP to %s", name) apply_hsdp_to_model(trans, hsdp_config, target_device=target_device) diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 3dc0d4899..4153eaa67 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -57,6 +57,7 @@ from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineL from vllm_omni.diffusion.models.interface import ( ReferenceVideoDecodeSpec, SupportImageInput, + SupportsComponentDiscovery, ) from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin @@ -628,7 +629,12 @@ def get_cosmos3_ir_op_priority_func(od_config: OmniDiffusionConfig): # Pipeline # --------------------------------------------------------------------------- class Cosmos3OmniDiffusersPipeline( - nn.Module, CFGParallelMixin, SupportImageInput, ProgressBarMixin, DiffusionPipelineProfilerMixin + nn.Module, + CFGParallelMixin, + SupportImageInput, + SupportsComponentDiscovery, + ProgressBarMixin, + DiffusionPipelineProfilerMixin, ): """Cosmos3 text/image/video/sound/action pipeline. @@ -671,6 +677,10 @@ class Cosmos3OmniDiffusersPipeline( support_image_input: ClassVar[bool] = True color_format: ClassVar[str] = "RGB" + _dit_modules: ClassVar[list[str]] = ["transformer.language_model", "transformer"] + _encoder_modules: ClassVar[list[str]] = [] + _vae_modules: ClassVar[list[str]] = ["vae"] + _resident_modules: ClassVar[list[str]] = [] @classmethod def reference_video_decode_spec( @@ -714,12 +724,6 @@ class Cosmos3OmniDiffusersPipeline( prefix: str = "", ) -> None: super().__init__() - if od_config.enable_cpu_offload: - raise ValueError( - "Cosmos3 has no separate text encoder, so CPU offloading " - "(transformer↔encoder swapping) is not supported. " - "Use --enable-layerwise-offload instead." - ) self.od_config = od_config self.device = get_local_device() self.dtype = od_config.dtype @@ -823,6 +827,32 @@ class Cosmos3OmniDiffusersPipeline( enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler ) + def enable_omni_model_cpu_offload( + self, + *, + device: torch.device, + pin_memory: bool = True, + use_hsdp: bool = False, + ) -> None: + """Enable Cosmos3 component-level model offload. + + Cosmos3 has a nested reasoner/generator transformer instead of separate + text-encoder and DiT pipeline components, so the transformer owns the + mutual-exclusion swaps. The VAE stays resident on GPU like the generic + model-level offloader. + """ + self.vae.to(device, non_blocking=True) + if isinstance(self._sound_tokenizer, nn.Module): + self._sound_tokenizer.to(device) + self.transformer.enable_model_cpu_offload( + device=device, + pin_memory=pin_memory, + use_hsdp=use_hsdp, + ) + + def disable_omni_model_cpu_offload(self) -> None: + self.transformer.disable_model_cpu_offload() + # -- Weight loading -------------------------------------------------------- @staticmethod diff --git a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py index 261573335..21e723ced 100644 --- a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py @@ -12,7 +12,9 @@ Ported from the TRT-LLM integration (tekit branch user/shreyasm/cosmos3). from __future__ import annotations import math -from typing import Any +from collections.abc import Iterator +from contextlib import AbstractContextManager, contextmanager, nullcontext +from typing import TYPE_CHECKING, Any import torch import torch.distributed as dist @@ -38,6 +40,9 @@ from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_ from vllm_omni.diffusion.layers.norm import RMSNorm as _VllmRMSNorm from vllm_omni.platforms import current_omni_platform +if TYPE_CHECKING: + from vllm_omni.diffusion.offloader.sequential_backend import SequentialOffloadHook + logger = init_logger(__name__) @@ -869,6 +874,8 @@ class Cosmos3LanguageModel(nn.Module): computed once and reused across all sampling steps. """ + _layerwise_offload_blocks_attrs = ["layers"] + def __init__( self, *, @@ -959,7 +966,13 @@ class Cosmos3VFMTransformer(nn.Module): runs at each denoising step over the target video/image latent stream and optional transfer-control, action, and sound latent streams. - Layerwise offloading uses ``gen_layers`` as the block container. + Layerwise offloading uses ``gen_layers`` as the GEN block container. The + nested UND language model declares its own ``layers`` container so the two + pathways are offloaded as independent rings. + + Model-level CPU offload is Cosmos3-local: the UND ``reasoner`` and GEN + ``generator`` components are swapped at the phase boundaries marked by + ``with self._offload_context(...)``. Sequence parallelism uses ``_sp_plan`` to shard/gather the GEN pathway at module boundaries. ``Cosmos3CrossAttention`` checks @@ -1148,10 +1161,125 @@ class Cosmos3VFMTransformer(nn.Module): self.cached_kv: list[tuple[torch.Tensor, torch.Tensor]] | None = None self.cached_freqs_gen: tuple[torch.Tensor, torch.Tensor] | None = None + self._model_cpu_offload_enabled = False + self._model_cpu_offload_device: torch.device | None = None + self._model_cpu_offload_mover: SequentialOffloadHook | None = None + self._active_model_cpu_offload_component: str | None = None + @property def device(self) -> torch.device: + offload_device = getattr(self, "_model_cpu_offload_device", None) + if getattr(self, "_model_cpu_offload_enabled", False) and offload_device is not None: + return offload_device return next(self.parameters()).device + def _model_cpu_offload_components(self) -> dict[str, list[nn.Module]]: + """Cosmos3's mutually-exclusive reasoner/generator component sets.""" + return { + "reasoner": [self.language_model.layers], + "generator": [self.gen_layers], + } + + def _model_cpu_offload_component_tensor_ids(self) -> set[int]: + component_tensors: set[int] = set() + for modules in self._model_cpu_offload_components().values(): + for module in modules: + component_tensors.update(id(param) for param in module.parameters()) + component_tensors.update(id(buffer) for buffer in module.buffers()) + return component_tensors + + def _move_model_cpu_offload_residents(self) -> None: + """Keep non-reasoner/non-generator weights resident on the target device.""" + component_tensors = self._model_cpu_offload_component_tensor_ids() + device = self._model_cpu_offload_device + if device is None: + return + with torch.no_grad(): + for param in self.parameters(): + if id(param) not in component_tensors and param.data.device != device: + param.data = param.data.to(device, non_blocking=False) + for buffer in self.buffers(): + if id(buffer) not in component_tensors and buffer.device != device: + buffer.data = buffer.data.to(device, non_blocking=False) + + def _offload_model_cpu_component(self, name: str) -> None: + mover = self._model_cpu_offload_mover + if mover is None: + raise RuntimeError("Cosmos3 model CPU offload is not enabled") + for module in self._model_cpu_offload_components()[name]: + mover._to_cpu(module) + + def _load_model_cpu_component(self, name: str) -> None: + mover = self._model_cpu_offload_mover + if mover is None: + raise RuntimeError("Cosmos3 model CPU offload is not enabled") + for module in self._model_cpu_offload_components()[name]: + mover._to_gpu(module) + + def enable_model_cpu_offload( + self, + *, + device: torch.device, + pin_memory: bool = True, + use_hsdp: bool = False, + ) -> None: + """Enable Cosmos3 reasoner/generator CPU swapping inside ``forward``.""" + if getattr(self, "_model_cpu_offload_enabled", False): + return + + from vllm_omni.diffusion.offloader.sequential_backend import SequentialOffloadHook + + self._model_cpu_offload_device = torch.device(device) + self._model_cpu_offload_mover = SequentialOffloadHook( + offload_targets=[], + device=self._model_cpu_offload_device, + pin_memory=pin_memory, + use_hsdp=use_hsdp, + ) + self._move_model_cpu_offload_residents() + for name in self._model_cpu_offload_components(): + self._offload_model_cpu_component(name) + self._model_cpu_offload_enabled = True + self._active_model_cpu_offload_component = None + logger.info("Cosmos3 component-level CPU offload enabled on %s", self._model_cpu_offload_device) + + def disable_model_cpu_offload(self) -> None: + if not getattr(self, "_model_cpu_offload_enabled", False): + return + for name in self._model_cpu_offload_components(): + self._load_model_cpu_component(name) + if self._model_cpu_offload_device is not None and self._model_cpu_offload_device.type != "cpu": + current_omni_platform.synchronize() + self._model_cpu_offload_enabled = False + self._model_cpu_offload_device = None + self._model_cpu_offload_mover = None + self._active_model_cpu_offload_component = None + + def _activate_model_cpu_offload_component(self, name: str) -> None: + if not getattr(self, "_model_cpu_offload_enabled", False): + return + components = self._model_cpu_offload_components() + if name not in components: + raise ValueError(f"Unknown Cosmos3 offload component: {name!r} (known: {list(components)})") + if self._active_model_cpu_offload_component == name: + return + self._active_model_cpu_offload_component = None + for other in components: + if other != name: + self._offload_model_cpu_component(other) + self._load_model_cpu_component(name) + self._active_model_cpu_offload_component = name + + @contextmanager + def _model_cpu_offload_context(self, name: str) -> Iterator[None]: + self._activate_model_cpu_offload_component(name) + yield + + def _offload_context(self, name: str) -> AbstractContextManager[None]: + if not getattr(self, "_model_cpu_offload_enabled", False): + return nullcontext() + return self._model_cpu_offload_context(name) + # -- Patchify / Unpatchify ----------------------------------------------- def _pad_to_patch_size(self, h: int, w: int) -> tuple[int, int, int, int]: @@ -1483,26 +1611,14 @@ class Cosmos3VFMTransformer(nn.Module): # Query Ulysses state at runtime ulysses_size, _, _ = _get_ulysses_state() - # Patchify latents and project to hidden space - hidden_video = self.proj_in(self.patchify(hidden_states, t, h, w)) - s_video = hidden_video.shape[1] - s_control = 0 - hidden_controls: list[torch.Tensor] = [] - for idx, control in enumerate(control_latent_list): - if control.shape != hidden_states.shape: - raise ValueError( - "Cosmos3 transfer control latent shape must match target latent shape: " - f"control[{idx}]={tuple(control.shape)}, target={tuple(hidden_states.shape)}." - ) - hidden_control = self.proj_in( - self.patchify(control.to(device=hidden_states.device, dtype=hidden_states.dtype), t, h, w) - ) - hidden_controls.append(hidden_control) - s_control += hidden_control.shape[1] + # Pack action/sound tokens (no learned weights) up front so the UND + # cache sizing knows their token lengths. The modality projections are + # deferred into the generator offload context below, so model-level + # offload does not stage GEN weights before swapping to the reasoner. + action_tokens = None + sound_tokens = None s_action = 0 - hidden_action = None s_sound = 0 - hidden_sound = None if action_latents is not None: if action_latents.shape[0] != hidden_states.shape[0]: raise ValueError( @@ -1511,60 +1627,16 @@ class Cosmos3VFMTransformer(nn.Module): ) if action_domain_ids is None: action_domain_ids = torch.zeros(action_latents.shape[0], dtype=torch.long, device=action_latents.device) - hidden_action = self.action_proj_in(self.pack_action(action_latents), action_domain_ids) - hidden_action = hidden_action + self.action_modality_embed.to(hidden_action.dtype) - s_action = hidden_action.shape[1] + action_tokens = self.pack_action(action_latents) + s_action = action_tokens.shape[1] if sound_latents is not None: if sound_latents.shape[0] != hidden_states.shape[0]: raise ValueError( "Cosmos3 sound and video batch sizes must match: " f"video={hidden_states.shape[0]}, sound={sound_latents.shape[0]}." ) - hidden_sound = self.audio_proj_in(self.pack_sound(sound_latents)) - hidden_sound = hidden_sound + self.audio_modality_embed.to(hidden_sound.dtype) - s_sound = hidden_sound.shape[1] - - # Timestep embedding (fp32 for precision). - # For I2V: only add to noisy tokens, not conditioned ones. - # Conditioned frames are clean context and should not receive - # the diffusion timestep signal. - with torch.autocast(current_omni_platform.device_type, enabled=False): - time_embed = self.time_embedder((timestep * self.timestep_scale).float()) - time_embed = time_embed.to(hidden_states.dtype) - - if noisy_frame_mask is not None: - # Build per-token mask from per-frame mask. - # noisy_frame_mask: [B, 1, t, 1, 1] → token mask: [B, t*hp*wp, 1] - token_noisy_mask = ( - noisy_frame_mask[:, 0, :, 0, 0] # [B, t] - .unsqueeze(-1) # [B, t, 1] - .expand(-1, -1, hp * wp) # [B, t, hp*wp] - .reshape(hidden_video.shape[0], -1, 1) # [B, t*hp*wp, 1] - ) - hidden_video = hidden_video + time_embed.unsqueeze(1) * token_noisy_mask - else: - hidden_video = hidden_video + time_embed.unsqueeze(1) - - if hidden_action is not None: - if action_noisy_mask is None: - hidden_action = hidden_action + time_embed.unsqueeze(1) - else: - if action_noisy_mask.shape != (hidden_action.shape[0], hidden_action.shape[1], 1): - raise ValueError( - "Cosmos3 action_noisy_mask must have shape [B, T_action, 1], " - f"got {tuple(action_noisy_mask.shape)}." - ) - action_noisy_mask = action_noisy_mask.to(dtype=hidden_action.dtype, device=hidden_action.device) - hidden_action = hidden_action + time_embed.unsqueeze(1) * action_noisy_mask - - if hidden_sound is not None: - hidden_sound = hidden_sound + time_embed.unsqueeze(1) - hidden_parts = [*hidden_controls, hidden_video] - if hidden_action is not None: - hidden_parts.append(hidden_action) - if hidden_sound is not None: - hidden_parts.append(hidden_sound) - hidden_gen = torch.cat(hidden_parts, dim=1) + sound_tokens = self.pack_sound(sound_latents) + s_sound = sound_tokens.shape[1] # Run UND pathway once and cache K/V (replicated across all ranks) if self.cached_kv is None: @@ -1583,90 +1655,162 @@ class Cosmos3VFMTransformer(nn.Module): num_vision_items=len(control_latent_list) + 1, share_vision_temporal_positions=transfer_share_vision_temporal_positions, ) - cached_kv_full = self.language_model(text_ids, freqs_und) + with self._offload_context("reasoner"): + cached_kv_full = self.language_model(text_ids, freqs_und) self.cached_freqs_gen = freqs_gen # Trim to real text length (remove padding). K/V stay replicated; # the framework Attention layer head-slices them via joint_key/value. self.cached_kv = [(k[:, :max_real_len], v[:, :max_real_len]) for k, v in cached_kv_full] - # Run GEN layers. UND K/V (replicated) is passed to each layer; - # the Cosmos3CrossAttention forwards them as joint_key/value so the - # framework Attention handles the Ulysses head-slicing internally. - if self.cached_kv is None or self.cached_freqs_gen is None: - raise RuntimeError("Cosmos3 GEN cache was not initialized before running GEN layers.") - self._validate_gen_sequence_parallel( - s_gen=hidden_gen.shape[1], - s_video=s_video, - s_control=s_control, - s_action=s_action, - s_sound=s_sound, - has_action=has_action, - has_sound=has_sound, - has_control=has_control, - ulysses_size=ulysses_size, - ) - freqs_cos, freqs_sin = self.cached_freqs_gen - hidden_gen, freqs_cos, freqs_sin = self.gen_sp_prepare(hidden_gen, freqs_cos, freqs_sin) - freqs_gen = (freqs_cos, freqs_sin) - - if len(self.gen_layers) == len(self.cached_kv): - for layer, (k_und, v_und) in zip(self.gen_layers, self.cached_kv, strict=True): - hidden_gen = layer( - hidden_gen, - k_und=k_und, - v_und=v_und, - freqs_cos=freqs_cos, - freqs_sin=freqs_sin, + with self._offload_context("generator"): + # Patchify latents and project to hidden space after UND cache + # construction, so model-level offload does not stage GEN weights + # before immediately swapping to the reasoner. + hidden_video = self.proj_in(self.patchify(hidden_states, t, h, w)) + s_video = hidden_video.shape[1] + s_control = 0 + hidden_controls: list[torch.Tensor] = [] + for idx, control in enumerate(control_latent_list): + if control.shape != hidden_states.shape: + raise ValueError( + "Cosmos3 transfer control latent shape must match target latent shape: " + f"control[{idx}]={tuple(control.shape)}, target={tuple(hidden_states.shape)}." + ) + hidden_control = self.proj_in( + self.patchify(control.to(device=hidden_states.device, dtype=hidden_states.dtype), t, h, w) ) - # Cache-dit's block wrapper may return a tuple; unwrap it. - if isinstance(hidden_gen, tuple): - hidden_gen = hidden_gen[0] - else: - # Cache-dit patches gen_layers to a grouped wrapper. - for layer in self.gen_layers: - hidden_gen = layer( - hidden_gen, - cached_kv=self.cached_kv, - freqs_gen=freqs_gen, + hidden_controls.append(hidden_control) + s_control += hidden_control.shape[1] + hidden_action = None + hidden_sound = None + if action_tokens is not None: + assert action_domain_ids is not None + hidden_action = self.action_proj_in(action_tokens, action_domain_ids) + hidden_action = hidden_action + self.action_modality_embed.to(hidden_action.dtype) + if sound_tokens is not None: + hidden_sound = self.audio_proj_in(sound_tokens) + hidden_sound = hidden_sound + self.audio_modality_embed.to(hidden_sound.dtype) + + # Timestep embedding (fp32 for precision). + # For I2V: only add to noisy tokens, not conditioned ones. + # Conditioned frames are clean context and should not receive + # the diffusion timestep signal. + with torch.autocast(current_omni_platform.device_type, enabled=False): + time_embed = self.time_embedder((timestep * self.timestep_scale).float()) + time_embed = time_embed.to(hidden_states.dtype) + + if noisy_frame_mask is not None: + # Build per-token mask from per-frame mask. + # noisy_frame_mask: [B, 1, t, 1, 1] → token mask: [B, t*hp*wp, 1] + token_noisy_mask = ( + noisy_frame_mask[:, 0, :, 0, 0] # [B, t] + .unsqueeze(-1) # [B, t, 1] + .expand(-1, -1, hp * wp) # [B, t, hp*wp] + .reshape(hidden_video.shape[0], -1, 1) # [B, t*hp*wp, 1] ) - if isinstance(hidden_gen, tuple): - hidden_gen = hidden_gen[0] + hidden_video = hidden_video + time_embed.unsqueeze(1) * token_noisy_mask + else: + hidden_video = hidden_video + time_embed.unsqueeze(1) - hidden_gen = self.gen_sp_gather(hidden_gen) + if hidden_action is not None: + if action_noisy_mask is None: + hidden_action = hidden_action + time_embed.unsqueeze(1) + else: + if action_noisy_mask.shape != (hidden_action.shape[0], hidden_action.shape[1], 1): + raise ValueError( + "Cosmos3 action_noisy_mask must have shape [B, T_action, 1], " + f"got {tuple(action_noisy_mask.shape)}." + ) + action_noisy_mask = action_noisy_mask.to(dtype=hidden_action.dtype, device=hidden_action.device) + hidden_action = hidden_action + time_embed.unsqueeze(1) * action_noisy_mask - # Final norm and project back to latent space - hidden_gen = self.norm_moe_gen(hidden_gen) - if not has_action and not has_sound and not has_control: - return self.unpatchify(self.proj_out(hidden_gen), t, h, w) + if hidden_sound is not None: + hidden_sound = hidden_sound + time_embed.unsqueeze(1) + hidden_parts = [*hidden_controls, hidden_video] + if hidden_action is not None: + hidden_parts.append(hidden_action) + if hidden_sound is not None: + hidden_parts.append(hidden_sound) + hidden_gen = torch.cat(hidden_parts, dim=1) - split_sizes = [] - if has_control: - split_sizes.append(s_control) - split_sizes.append(s_video) - if has_action: - split_sizes.append(s_action) - if has_sound: - split_sizes.append(s_sound) - split_hidden = hidden_gen.split(split_sizes, dim=1) - split_idx = 0 - if has_control: + # Run GEN layers. UND K/V (replicated) is passed to each layer; + # the Cosmos3CrossAttention forwards them as joint_key/value so the + # framework Attention handles the Ulysses head-slicing internally. + if self.cached_kv is None or self.cached_freqs_gen is None: + raise RuntimeError("Cosmos3 GEN cache was not initialized before running GEN layers.") + self._validate_gen_sequence_parallel( + s_gen=hidden_gen.shape[1], + s_video=s_video, + s_control=s_control, + s_action=s_action, + s_sound=s_sound, + has_action=has_action, + has_sound=has_sound, + has_control=has_control, + ulysses_size=ulysses_size, + ) + freqs_cos, freqs_sin = self.cached_freqs_gen + hidden_gen, freqs_cos, freqs_sin = self.gen_sp_prepare(hidden_gen, freqs_cos, freqs_sin) + freqs_gen = (freqs_cos, freqs_sin) + + if len(self.gen_layers) == len(self.cached_kv): + for layer, (k_und, v_und) in zip(self.gen_layers, self.cached_kv, strict=True): + hidden_gen = layer( + hidden_gen, + k_und=k_und, + v_und=v_und, + freqs_cos=freqs_cos, + freqs_sin=freqs_sin, + ) + # Cache-dit's block wrapper may return a tuple; unwrap it. + if isinstance(hidden_gen, tuple): + hidden_gen = hidden_gen[0] + else: + # Cache-dit patches gen_layers to a grouped wrapper. + for layer in self.gen_layers: + hidden_gen = layer( + hidden_gen, + cached_kv=self.cached_kv, + freqs_gen=freqs_gen, + ) + if isinstance(hidden_gen, tuple): + hidden_gen = hidden_gen[0] + + hidden_gen = self.gen_sp_gather(hidden_gen) + + # Final norm and project back to latent space + hidden_gen = self.norm_moe_gen(hidden_gen) + if not has_action and not has_sound and not has_control: + return self.unpatchify(self.proj_out(hidden_gen), t, h, w) + + split_sizes = [] + if has_control: + split_sizes.append(s_control) + split_sizes.append(s_video) + if has_action: + split_sizes.append(s_action) + if has_sound: + split_sizes.append(s_sound) + split_hidden = hidden_gen.split(split_sizes, dim=1) + split_idx = 0 + if has_control: + split_idx += 1 + hidden_video = split_hidden[split_idx] split_idx += 1 - hidden_video = split_hidden[split_idx] - split_idx += 1 - video_pred = self.unpatchify(self.proj_out(hidden_video), t, h, w) - if has_control: - return video_pred - outputs: list[torch.Tensor] = [video_pred] - if has_action: - hidden_action = split_hidden[split_idx] - split_idx += 1 - assert action_domain_ids is not None - outputs.append(self.unpack_action(self.action_proj_out(hidden_action, action_domain_ids))) - if has_sound: - hidden_sound = split_hidden[split_idx] - outputs.append(self.unpack_sound(self.audio_proj_out(hidden_sound))) - return tuple(outputs) + video_pred = self.unpatchify(self.proj_out(hidden_video), t, h, w) + if has_control: + return video_pred + outputs: list[torch.Tensor] = [video_pred] + if has_action: + hidden_action = split_hidden[split_idx] + split_idx += 1 + assert action_domain_ids is not None + outputs.append(self.unpack_action(self.action_proj_out(hidden_action, action_domain_ids))) + if has_sound: + hidden_sound = split_hidden[split_idx] + outputs.append(self.unpack_sound(self.audio_proj_out(hidden_sound))) + return tuple(outputs) def post_load_weights(self) -> None: """Post-load processing: ensure correct dtypes.""" diff --git a/vllm_omni/diffusion/offloader/module_collector.py b/vllm_omni/diffusion/offloader/module_collector.py index 320f9bf38..17d4ca2fd 100644 --- a/vllm_omni/diffusion/offloader/module_collector.py +++ b/vllm_omni/diffusion/offloader/module_collector.py @@ -22,6 +22,38 @@ class PipelineModules: resident_modules: list[nn.Module] = field(default_factory=list) resident_names: list[str] = field(default_factory=list) + def outermost_dits(self) -> tuple[list[str], list[nn.Module]]: + """Discovered DiTs that are not nested inside another discovered DiT. + + Some pipelines declare a DiT and one of its own submodules as separate + DiTs (e.g. Cosmos3 lists both ``transformer`` and the nested + ``transformer.language_model``) so that offloading can treat them as + independent rings. HSDP instead shards each DiT through that DiT's own + ``_hsdp_shard_conditions``; an inner DiT's blocks are already covered by + its ancestor's conditions, so sharding it again would double-wrap them + (and the inner stack may not declare conditions at all). Keeping only the + outermost DiTs leaves the ancestor's conditions as the single source of + truth for every nested block. Order follows ``dit_names``. + """ + + def is_nested_in_another_dit(dit: nn.Module) -> bool: + for other in self.dits: + if other is dit: + continue + for submodule in other.modules(): + if submodule is dit: + return True + return False + + outer_names: list[str] = [] + outer_modules: list[nn.Module] = [] + for name, dit in zip(self.dit_names, self.dits): + if is_nested_in_another_dit(dit): + continue + outer_names.append(name) + outer_modules.append(dit) + return outer_names, outer_modules + class ModuleDiscovery: """Discovers pipeline components. diff --git a/vllm_omni/diffusion/offloader/sequential_backend.py b/vllm_omni/diffusion/offloader/sequential_backend.py index 0edf77651..0b031be3a 100644 --- a/vllm_omni/diffusion/offloader/sequential_backend.py +++ b/vllm_omni/diffusion/offloader/sequential_backend.py @@ -197,12 +197,35 @@ class ModelLevelOffloadBackend(OffloadBackend): def __init__(self, config: OffloadConfig, device: torch.device): super().__init__(config, device) self._offload_modules: list[nn.Module] = [] # Track modules with hooks + self._custom_pipeline: nn.Module | None = None def enable(self, pipeline: nn.Module) -> None: if self.enabled: logger.warning("ModelLevelOffloadBackend already enabled") return + # Pipelines with a nested transformer (e.g. Cosmos3's reasoner/generator + # pathways) own their own mutual-exclusion swaps and cannot be offloaded + # with the generic encoder<->DiT hooks. Delegate to the custom hook. + # TODO: Cosmos3 is the only implementer today, so we duck-type the + # enable/disable_omni_model_cpu_offload pair via getattr. Once a second + # pipeline needs it, formalize this as a Protocol (like + # SupportsComponentDiscovery) instead of getattr detection. + custom_enable = getattr(pipeline, "enable_omni_model_cpu_offload", None) + if callable(custom_enable): + custom_enable( + device=self.device, + pin_memory=self.config.pin_cpu_memory, + use_hsdp=self.config.use_hsdp, + ) + self._custom_pipeline = pipeline + self.enabled = True + logger.info( + "Model-level offloading enabled through %s.enable_omni_model_cpu_offload", + pipeline.__class__.__name__, + ) + return + modules = ModuleDiscovery.discover(pipeline) # Move encoders to GPU @@ -259,6 +282,15 @@ class ModelLevelOffloadBackend(OffloadBackend): if not self.enabled: return + if self._custom_pipeline is not None: + custom_disable = getattr(self._custom_pipeline, "disable_omni_model_cpu_offload", None) + if callable(custom_disable): + custom_disable() + self._custom_pipeline = None + self.enabled = False + logger.info("Model-level offloading disabled") + return + remove_sequential_offload(self._offload_modules) self._offload_modules.clear() From 625892032bad0e6a7fd7a9bba16cb71311b4893d Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Mon, 13 Jul 2026 23:59:42 +0800 Subject: [PATCH 076/101] [Doc] Standardize serving names and identifiers (#5081) Signed-off-by: hsliu_ustc Co-authored-by: hsliu_ustc --- docs/.nav.yml | 4 +- docs/cli/README.md | 10 +++- docs/cli/bench/serve.md | 10 ++-- docs/configuration/composable_parallel.md | 6 +-- docs/configuration/stage_configs.md | 35 +++++++------ docs/contributing/DOCS_GUIDE.md | 15 ++++++ .../model/adding_diffusion_model.md | 2 +- docs/design/module/dit_module.md | 6 +-- docs/features/custom_pipeline.md | 12 ++--- docs/mkdocs/hooks/generate_examples.py | 52 ++++++++++++++++++- docs/serving/image_edit_api.md | 2 +- docs/serving/image_generation_api.md | 2 +- .../diffusion/attention_backends.md | 2 +- .../diffusion/cpu_offload_diffusion.md | 6 +-- .../examples/offline_inference/bagel.md | 2 +- .../examples/offline_inference/glm_image.md | 36 ++++++++----- .../examples/offline_inference/mimo_audio.md | 2 +- .../offline_inference/qwen2_5_omni.md | 2 +- .../examples/offline_inference/qwen3_omni.md | 2 +- .../examples/online_serving/aura_omni.md | 2 +- .../examples/online_serving/mimo_audio.md | 2 +- .../examples/online_serving/qwen2_5_omni.md | 2 +- .../examples/online_serving/qwen3_omni.md | 2 +- examples/model_display_names.yml | 12 +++++ .../offline_inference/aura_omni/README.md | 2 +- .../offline_inference/covo_audio/README.md | 2 +- .../offline_inference/dynin_omni/README.md | 2 +- examples/offline_inference/lance/README.md | 2 +- .../offline_inference/mimo_audio/README.md | 2 +- .../ming_flash_omni/README.md | 2 +- .../offline_inference/qwen2_5_omni/README.md | 2 +- .../offline_inference/qwen3_omni/README.md | 2 +- .../offline_inference/sensenova_u1/README.md | 2 +- .../offline_inference/step_audio2/README.md | 2 +- examples/online_serving/aura_omni/README.md | 2 +- examples/online_serving/covo_audio/README.md | 2 +- examples/online_serving/dynin_omni/README.md | 2 +- examples/online_serving/lance/README.md | 2 +- examples/online_serving/mimo_audio/README.md | 2 +- .../online_serving/ming_flash_omni/README.md | 2 +- .../online_serving/qwen2_5_omni/README.md | 2 +- examples/online_serving/qwen3_omni/README.md | 2 +- .../online_serving/sensenova_u1/README.md | 2 +- examples/online_serving/step_audio2/README.md | 2 +- tests/docs/test_generate_examples.py | 42 +++++++++++++++ 45 files changed, 223 insertions(+), 87 deletions(-) create mode 100644 examples/model_display_names.yml create mode 100644 tests/docs/test_generate_examples.py diff --git a/docs/.nav.yml b/docs/.nav.yml index 2e7097efb..f94907a67 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -23,7 +23,7 @@ nav: - Image-To-Video: user_guide/examples/offline_inference/image_to_video.md - InternVLA-A1: user_guide/examples/offline_inference/internvla_a1.md - MammothModa2-Preview: user_guide/examples/offline_inference/mammothmodal2_preview.md - - MiMo-Audio Offline Inference: user_guide/examples/offline_inference/mimo_audio.md + - MiMo-Audio: user_guide/examples/offline_inference/mimo_audio.md - Qwen2.5-Omni: user_guide/examples/offline_inference/qwen2_5_omni.md - Qwen3-Omni: user_guide/examples/offline_inference/qwen3_omni.md - Text-To-Audio: user_guide/examples/offline_inference/text_to_audio.md @@ -38,7 +38,7 @@ nav: - GLM-Image Online Serving: user_guide/examples/online_serving/glm_image.md - Image-To-Image: user_guide/examples/online_serving/image_to_image.md - Image-To-Video: user_guide/examples/online_serving/image_to_video.md - - Online serving Example of vLLM-Omni for MiMo-Audio: user_guide/examples/online_serving/mimo_audio.md + - MiMo-Audio: user_guide/examples/online_serving/mimo_audio.md - Qwen2.5-Omni: user_guide/examples/online_serving/qwen2_5_omni.md - Qwen3-Omni: user_guide/examples/online_serving/qwen3_omni.md - Text-To-Speech: user_guide/examples/online_serving/text_to_speech.md diff --git a/docs/cli/README.md b/docs/cli/README.md index 1fcfdb14e..9e3240de4 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -18,11 +18,17 @@ Specify the port: vllm serve Qwen/Qwen2.5-Omni-7B --omni --port 8091 ``` -If you have custom stage configs file, launch the server with command below +For a migrated model, load a custom deploy configuration with `--deploy-config`: + ```bash -vllm serve Qwen/Qwen2.5-Omni-7B --omni --stage-configs-path /path/to/stage_configs_file +vllm serve Qwen/Qwen2.5-Omni-7B --omni --deploy-config /path/to/deploy_config.yaml ``` +The deprecated `--stage-configs-path` flag is retained for models that still use the legacy `stage_args` schema: + +```bash +vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --stage-configs-path /path/to/legacy_stage_config.yaml +``` ## bench diff --git a/docs/cli/bench/serve.md b/docs/cli/bench/serve.md index d6b3ef393..2a1acd1f0 100644 --- a/docs/cli/bench/serve.md +++ b/docs/cli/bench/serve.md @@ -2,7 +2,7 @@ The vllm bench command launches the vLLM-Omni benchmark to evaluate the performance of multimodal models. ## Notes -We currently only support using the "openai-chat-omni" and "openai-image-edits-omni" backend. +vLLM-Omni registers the `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, and `daily-omni` serving benchmark backends. ## Basic Parameter Description You can use `vllm bench serve --omni --help=all` to get descriptions of all parameters. The commonly used parameters are described below: @@ -10,7 +10,7 @@ You can use `vllm bench serve --omni --help=all` to get descriptions of all para Enable Omni (multimodal) mode, supporting multimodal inputs and outputs such as images, videos, and audio. - `--backend` - Specify the backend adapter as openai-chat-omni, using OpenAI Chat compatible API behavior as the protocol. Currently only openai-chat-omni is supported. + Specify the backend adapter. vLLM-Omni adds `openai-chat-omni`, `openai-audio-speech`, `openai-image-edits-omni`, and `daily-omni` to the upstream vLLM backend choices. - `--model` The model identifier to load, filled according to the models supported by vLLM-Omni. @@ -55,7 +55,7 @@ You can use `vllm bench serve --omni --help=all` to get descriptions of all para 'Allowed metric names are "ttft", "tpot", "itl", "ttfc", "tpoc", "icl", ' '"tpop", "e2el", "audio_ttfp", "audio_rtf", "audio_duration". ' -- `-print-stage` +- `--print-stage` Print per-stage benchmark metrics for --omni serving when stage metrics are returned by the server. Disabled by default. - `--save-result` @@ -312,7 +312,7 @@ vllm bench serve \ --ignore-eos \ --percentile-metrics ttft,tpot,itl \ --random-output-len 2 \ - --extra_body '{"modalities": ["text"]}' + --extra-body '{"modalities": ["text"]}' ``` If successful, you will see the following output: @@ -390,7 +390,7 @@ vllm bench serve --omni \ --ignore-eos \ --percentile-metrics ttft,tpot,itl,e2el,audio_ttfp,audio_rtf,ttfc,tpoc,icl \ --random-output-len 900 \ - --extra_body '{"modalities": ["text","audio"]}' \ + --extra-body '{"modalities": ["text","audio"]}' \ --print-stage ``` diff --git a/docs/configuration/composable_parallel.md b/docs/configuration/composable_parallel.md index 400fbab38..a456de843 100644 --- a/docs/configuration/composable_parallel.md +++ b/docs/configuration/composable_parallel.md @@ -92,12 +92,12 @@ Full pattern hierarchy: `TakeRank`, `Union`, `GatherDim`, `AllGather`, `StitchSp From highest to lowest, when the same axis is touched by multiple layers: -1. **CLI overrides** (`--stage_N_tensor_parallel_size`, `--stage-overrides`, `--omni-lb-policy`, etc.). A CLI value always wins; a warning is emitted whenever it overrides a strategy-declared axis (see `_reconcile_strategy_with_cli` in `vllm_omni/config/config_factory.py`). +1. **CLI overrides** (`--tensor-parallel-size`, `--stage-overrides`, `--omni-lb-policy`, etc.). A CLI value always wins; a warning is emitted whenever it overrides a strategy-declared axis (see `_reconcile_strategy_with_cli` in `vllm_omni/config/config_factory.py`). 2. **Strategy YAML** (`--strategy-config`). Derived sizing is written onto the merged stages with *conflict-on-explicit* semantics — if the deploy YAML already set a value to something different, application raises `StrategyApplyError`. 3. **Deploy YAML** (`--deploy-config` or the bundled `vllm_omni/deploy/.yaml`). 4. **Parser defaults.** -After CLI overrides land, the device-layout guard re-runs (`check_device_layout`, called from `_reconcile_strategy_with_cli`) against the *effective* `tp * dp * pp * num_replicas` and the resolved `devices` string, so a `--devices` or `--tensor-parallel-size` that conflicts with the strategy's world size cannot slip past the pre-spawn check. +After CLI overrides land, the device-layout guard re-runs (`check_device_layout`, called from `_reconcile_strategy_with_cli`) against the *effective* `tp * dp * pp * num_replicas` and the resolved `devices` string, so a `devices` value passed through `--stage-overrides` or a `--tensor-parallel-size` value that conflicts with the strategy's world size cannot slip past the pre-spawn check. ## Worked example @@ -214,7 +214,7 @@ Strategy keys MUST be `model_stage` *names* (strings), the same labels stage con Use the `model_stage` name (e.g. `thinker`) instead. !!! warning "Mixing CLI overrides with a strategy on the same axis" - A strategy is meant to be the single writer for the axes it declares. If a CLI override (`--stage_0_tensor_parallel_size`, `--stage-overrides '{"0": {...}}'`) sets the same axis, the **CLI value wins** and a warning is emitted from `_reconcile_strategy_with_cli`. The device-layout guard then re-runs against the effective layout, so a CLI override that breaks `tp * dp * pp * num_replicas` will still fail fast at config time. + A strategy is meant to be the single writer for the axes it declares. If a CLI override (`--tensor-parallel-size 2`, `--stage-overrides '{"0": {"tensor_parallel_size": 2}}'`) sets the same axis, the **CLI value wins** and a warning is emitted from `_reconcile_strategy_with_cli`. The device-layout guard then re-runs against the effective layout, so a CLI override that breaks `tp * dp * pp * num_replicas` will still fail fast at config time. !!! warning "Passing `--strategy-config` against the legacy `--stage-configs-path`" Composable parallel only applies on the registry-based deploy path. If a model still resolves through the legacy `stage_args` YAML (loaded via `--stage-configs-path`), `--strategy-config` is silently inapplicable and emits: diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md index 803c74e0d..1f14d21fd 100644 --- a/docs/configuration/stage_configs.md +++ b/docs/configuration/stage_configs.md @@ -1,11 +1,11 @@ -# Stage configs for vLLM-Omni +# Deploy and legacy stage configurations -In vLLM-Omni, the target model is separated into multiple stages, which are processed by different LLMEngines, DiffusionEngines or other types of engines. Depending on different types of stages, such as Autoregressive (AR) stage or Diffusion transformer (DiT) stage, each can choose corresponding schedulers, model workers to load with the Engines in a plug-in fashion. +In vLLM-Omni, a model's `PipelineConfig` defines its fixed stage topology, while a deploy configuration controls how those stages run. Models that have not migrated to this split still use legacy stage configurations with a `stage_args` schema. !!! note Default deploy config YAMLs (for example, `vllm_omni/deploy/qwen2_5_omni.yaml`, `vllm_omni/deploy/qwen3_omni_moe.yaml`, and `vllm_omni/deploy/qwen3_tts.yaml`) are bundled and loaded automatically when neither `--stage-configs-path` nor `--deploy-config` is provided — the model registry resolves the right pipeline + deploy YAML by `model_type`. The bundled defaults have been verified on 1xH100 for Qwen2.5-Omni and 2xH100 for Qwen3-Omni. Models that have not yet migrated to the new schema continue to use the legacy `vllm_omni/model_executor/stage_configs/.yaml` files via `--stage-configs-path`. -## New deploy schema reference +## Deploy configuration schema The new deploy schema lives under `vllm_omni/deploy/` and is paired with a frozen `PipelineConfig` registered by the model's `pipeline.py`. Each deploy YAML has these top-level fields: @@ -82,14 +82,14 @@ stages: input_connectors: {from_stage_0: shm} ``` -### CLI flags introduced in this refactor +### CLI flags | Flag | Description | |------|-------------| -| `--deploy-config PATH` | Load a new-schema deploy YAML. Takes precedence over `--stage-configs-path`. **Optional** — when omitted, the bundled `vllm_omni/deploy/.yaml` is auto-loaded by the model registry. | +| `--deploy-config PATH` | Load a new-schema deploy YAML. It is mutually exclusive with `--stage-configs-path`. **Optional** — when omitted, the bundled `vllm_omni/deploy/.yaml` is auto-loaded by the model registry. | | `--stage-overrides JSON` | Per-stage JSON overrides, e.g. `'{"0":{"gpu_memory_utilization":0.5}}'`. Per-stage values always win over global flags. | | `--async-chunk` / `--no-async-chunk` | Flip the deploy YAML's `async_chunk:` bool. Unset (default) leaves the YAML value in force. | -| `--stage-configs-path` | **Deprecated.** Accepts legacy `stage_args` yamls and (auto-detected) new deploy yamls; emits a deprecation warning. Migrate to `--deploy-config`. To be removed in a follow-up PR. | +| `--stage-configs-path` | **Deprecated.** Load a legacy `stage_args` YAML. New-schema YAMLs passed to this flag are auto-detected for compatibility, but should use `--deploy-config`. It is mutually exclusive with `--deploy-config` and will be removed in a future release. | ### Stage-Based CLI Paradigm @@ -146,7 +146,7 @@ CUDA_VISIBLE_DEVICES=1 vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni \ From highest to lowest: -1. Per-stage flags (`--stage-overrides` JSON, `--stage--` if registered) +1. Per-stage overrides (`--stage-overrides` JSON) 2. Explicit global CLI flags (`--gpu-memory-utilization 0.85`, etc.) 3. Platform section (`platforms.npu.stages`, etc.) on top of the base `stages:` 4. Overlay YAML (via `base_config:`) on top of the base YAML @@ -210,7 +210,7 @@ Effective config per stage after the merge: | 1 | `max_model_len` | `16384` | global CLI | | 2 | (all defaults) | — | base YAML (no overrides apply) | -Therefore, as a core part of vLLM-Omni, the stage configs for a model have several main functions: +Therefore, as a core part of vLLM-Omni, a model's pipeline and deployment configurations have several main functions: - Claim partition of stages and their corresponding class implementation in `model_executor/models`. - The disaggregated configuration for each stage and the communication topology among them. @@ -219,16 +219,16 @@ Therefore, as a core part of vLLM-Omni, the stage configs for a model have sever - Default input parameters. To override specific parameters, explicitly inject the customized configuration schema -in both online and offline instantiation flows. Prioritize the `--deploy-config` flag -when loading the new-schema deploy YAML schemas, reserving the `--stage-configs-path` parameter +in both online and offline instantiation flows. Use the `--deploy-config` flag +when loading a deploy configuration, reserving the `--stage-configs-path` parameter exclusively to maintain compatibility with legacy `stage_args` YAML constructs. Examples: -For offline (Assume necessary dependencies have been imported): +For offline inference (assuming the necessary dependencies have been imported): ```python model_name = "Qwen/Qwen2.5-Omni-7B" -omni = Omni(model=model_name, stage_configs_path="/path/to/custom_stage_configs.yaml") +omni = Omni(model=model_name, deploy_config="/path/to/deploy_config.yaml") ``` For online serving: @@ -242,11 +242,14 @@ Legacy online serving: vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 --stage-configs-path /path/to/stage_configs_file ``` !!! important - We are actively iterating on the definition of stage configs, and we welcome all feedbacks from both community users and developers to help us shape the development! + We are actively iterating on the definition of deployment configurations, and we welcome feedback from users and developers. + +## Legacy stage configuration reference + +The remainder of this page documents the deprecated `stage_args` schema for models that have not migrated to deploy configurations. The following historical Qwen2.5-Omni configuration illustrates that schema; current Qwen2.5-Omni deployments should use `vllm_omni/deploy/qwen2_5_omni.yaml` instead. -Below is a specific example of stage_configs.yaml in Qwen2.5-omni. ```python -# stage config for running qwen2.5-omni with AsyncOmniEngine + Orchestrator runtime. +# Historical stage configuration for Qwen2.5-Omni. stage_args: - stage_id: 0 # mark the unique id for each stage runtime: # The disaggregated configuration @@ -338,7 +341,7 @@ runtime: ``` -## Stage Configuration Arguments +## Legacy stage configuration arguments Each stage in the `stage_args` list contains the following configuration options: diff --git a/docs/contributing/DOCS_GUIDE.md b/docs/contributing/DOCS_GUIDE.md index 100bac674..916f5e60f 100644 --- a/docs/contributing/DOCS_GUIDE.md +++ b/docs/contributing/DOCS_GUIDE.md @@ -67,6 +67,21 @@ docs/ └── stylesheets/ # Custom CSS ``` +## Naming Model Examples + +Offline inference and online serving examples for the same model use the same +directory name and the shared display name in +`examples/model_display_names.yml`. Use these title forms: + +- `# : Offline inference` +- `# : Online serving` + +The documentation generator uses the full title for the page H1 and the shared +display name alone for navigation, and fails the build if a mapped README uses +a different H1. Keep checkpoint identifiers in commands and prose rather than +in the display name, and do not rename an existing example directory solely to +adjust its title because the directory defines its public documentation URL. + ## Publishing Documentation ### GitHub Pages (Recommended) diff --git a/docs/contributing/model/adding_diffusion_model.md b/docs/contributing/model/adding_diffusion_model.md index 6703dd9c7..b38805112 100644 --- a/docs/contributing/model/adding_diffusion_model.md +++ b/docs/contributing/model/adding_diffusion_model.md @@ -638,7 +638,7 @@ mkdir -p examples/online_serving/your_model_name - Script: `examples/offline_inference/your_model_name/end2end.py` - Parse args like BAGEL (`--model`, `--modality`, optional `--image-path`, `--steps`, etc.) - - Use `from vllm_omni.entrypoints.omni import Omni` (or `OmniDiffusion` if your model is diffusion-only) + - Use `from vllm_omni.entrypoints.omni import Omni` for both multi-stage and diffusion-only models - Save outputs (images/audio/video/text) with deterministic filenames (e.g., `output_0_0.png`) - Doc: `examples/offline_inference/your_model_name/README.md` - Include at least one runnable command, e.g.: diff --git a/docs/design/module/dit_module.md b/docs/design/module/dit_module.md index 953e11f7f..02d50181b 100644 --- a/docs/design/module/dit_module.md +++ b/docs/design/module/dit_module.md @@ -470,7 +470,7 @@ class Attention(nn.Module): role="self", role_category=None, ...): # Resolve backend for this role from the active OmniDiffusionConfig config = get_current_diffusion_config_or_none() - attention_config = config.attention_config if config is not None else None + attention_config = config.diffusion_attention_config if config is not None else None attn_backend_cls, spec = get_attn_backend_for_role( role=role, head_size=head_size, @@ -493,7 +493,7 @@ These backends provide the **kernel implementations** for attention computation. #### Backend Selection Mechanism -Selection is driven by `AttentionConfig` on `OmniDiffusionConfig`, which carries a global `default` plus a `per_role` map of `AttentionSpec` entries. A role string is resolved in this order: +Selection is driven by `OmniDiffusionConfig.diffusion_attention_config`, which carries a global `default` plus a `per_role` map of `AttentionSpec` entries. A role string is resolved in this order: ```python def get_attn_backend_for_role(role, head_size, attention_config=None, role_category=None): @@ -909,7 +909,7 @@ def initialize_model_parallel( ``` 1. User Request - └─> OmniDiffusion.generate(prompt) + └─> Omni.generate(prompt) └─> Prepare OmniDiffusionRequest └─> DiffusionEngine.step(requests) diff --git a/docs/features/custom_pipeline.md b/docs/features/custom_pipeline.md index 31334526a..78d680f26 100644 --- a/docs/features/custom_pipeline.md +++ b/docs/features/custom_pipeline.md @@ -10,7 +10,7 @@ This guide demonstrates how to use the newly added features for extending vLLM-O Three main features enable custom pipeline extension: 1. **`WorkerWrapperBase`**: A wrapper class that enables dynamic worker extension with custom functionality -2. **`load_format`**: A parameter that controls how diffusion models are loaded, including support for custom pipelines +2. **`diffusion_load_format`**: A parameter that controls how diffusion models are loaded, including support for custom pipelines 3. **`CustomPipelineWorkerExtension`**: An extension class that enables pipeline re-initialization with custom implementations ## Features @@ -27,15 +27,15 @@ Three main features enable custom pipeline extension: **Location:** `vllm_omni/diffusion/worker/diffusion_worker.py` -### load_format Parameter +### diffusion_load_format parameter -The `load_format` parameter controls how diffusion models are loaded. It supports the following values: +The `diffusion_load_format` parameter controls the initial diffusion model load. The relevant values are: - **`"default"`**: Standard model loading using the model registry (default behavior) -- **`"custom_pipeline"`**: Load a custom pipeline class specified by `custom_pipeline_name` -- **`"dummy"`**: Skip model loading (useful for testing or when pipeline will be initialized separately) +- **`"dummy"`**: Skip the initial model load; use this with `custom_pipeline_args["pipeline_class"]` for the custom pipeline workflow below +- **`"diffusers"`**: Load through the Hugging Face Diffusers adapter -**Location:** `vllm_omni/diffusion/model_loader/diffusers_loader.py` +**Location:** `vllm_omni/diffusion/data.py` (`OmniDiffusionConfig`) ### CustomPipelineWorkerExtension diff --git a/docs/mkdocs/hooks/generate_examples.py b/docs/mkdocs/hooks/generate_examples.py index 81f2bac24..6cb601b96 100644 --- a/docs/mkdocs/hooks/generate_examples.py +++ b/docs/mkdocs/hooks/generate_examples.py @@ -16,8 +16,35 @@ ROOT_DIR_RELATIVE = "../../../../.." EXAMPLE_DIR = ROOT_DIR / "examples" EXAMPLE_DOC_DIR = ROOT_DIR / "docs/user_guide/examples" NAV_FILE = ROOT_DIR / "docs/.nav.yml" +MODEL_DISPLAY_NAMES_FILE = EXAMPLE_DIR / "model_display_names.yml" MAX_INLINE_MATERIAL_SIZE = 8 * 1024 +SERVING_MODE_TITLES = { + "offline_inference": "Offline inference", + "online_serving": "Online serving", +} + + +def load_model_display_names() -> dict[str, str]: + try: + with open(MODEL_DISPLAY_NAMES_FILE, encoding="utf-8") as f: + model_display_names = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as exc: + raise ValueError(f"Failed to load MODEL_DISPLAY_NAMES_FILE at {MODEL_DISPLAY_NAMES_FILE}: {exc}") from exc + + if not isinstance(model_display_names, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in model_display_names.items() + ): + raise ValueError( + "MODEL_DISPLAY_NAMES_FILE " + f"at {MODEL_DISPLAY_NAMES_FILE} must contain a YAML mapping " + "of string model IDs to string display names" + ) + return model_display_names + + +MODEL_DISPLAY_NAMES = load_model_display_names() + def fix_case(text: str) -> str: subs = { @@ -54,6 +81,7 @@ class Example: main_file (Path): The main file in the directory. other_files (list[Path]): list of other files in the directory. title (str): The title of the document. + nav_title (str): The concise title used in navigation. Methods: __post_init__(): Initializes the main_file, other_files, and title attributes. @@ -78,6 +106,16 @@ class Example: def is_code(self) -> bool: return self.main_file.suffix != ".md" + @property + def model_display_name(self) -> str | None: + if self.category not in SERVING_MODE_TITLES: + return None + return MODEL_DISPLAY_NAMES.get(self.path.stem) + + @property + def nav_title(self) -> str: + return self.model_display_name or self.title + def determine_main_file(self) -> Path: """ Determines the main file in the given path. @@ -151,13 +189,25 @@ class Example: return [file for file in self.path.rglob("*") if is_other_file(file)] def determine_title(self) -> str: + model_display_name = self.model_display_name if not self.is_code: # Specify encoding for building on Windows with open(self.main_file, encoding="utf-8") as f: first_line = f.readline().strip() match = re.match(r"^#\s+(?P.+)$", first_line) + if model_display_name: + expected_title = f"{model_display_name}: {SERVING_MODE_TITLES[self.category]}" + actual_title = match.group("title") if match else first_line + if actual_title != expected_title: + raise ValueError( + f"Model example title mismatch in {self.main_file}: " + f"expected '# {expected_title}', got {first_line!r}" + ) + return expected_title if match: return match.group("title") + elif model_display_name: + raise ValueError(f"Mapped model example must use a Markdown README: {self.path}") return fix_case(self.path.stem.replace("_", " ").title()) def fix_relative_links(self, content: str) -> str: @@ -300,7 +350,7 @@ def update_nav_file(examples: list[Example]): for example in category_examples: doc_path = EXAMPLE_DOC_DIR / example.category / f"{example.path.stem}.md" rel_path = doc_path.relative_to(ROOT_DIR / "docs") - category_items.append({example.title: rel_path.as_posix()}) + category_items.append({example.nav_title: rel_path.as_posix()}) if category_items: # Format category name (e.g., "offline_inference" -> "Offline Inference") diff --git a/docs/serving/image_edit_api.md b/docs/serving/image_edit_api.md index f64d37f44..fef28142e 100644 --- a/docs/serving/image_edit_api.md +++ b/docs/serving/image_edit_api.md @@ -252,7 +252,7 @@ Validation errors (missing required fields): ```bash # Check if server is responding -curl -X http://localhost:8000/v1/images/edit \ +curl http://localhost:8000/v1/images/edits \ -F "prompt='test'" ``` diff --git a/docs/serving/image_generation_api.md b/docs/serving/image_generation_api.md index 4702a19c1..12e2935ea 100644 --- a/docs/serving/image_generation_api.md +++ b/docs/serving/image_generation_api.md @@ -138,7 +138,7 @@ Content-Type: application/json | `model` | string | server's model | Model to use (optional, should match server if specified) | | `n` | integer | 1 | Number of images to generate (1-10) | | `size` | string | model defaults | Image dimensions in WxH format (e.g., "1024x1024", "512x512") | -| `response_format` | string | "b64_json" | Response format (only "b64_json" supported) | +| `response_format` | string | "b64_json" | Response format (`"b64_json"` or `"file"`) | | `user` | string | null | User identifier for tracking | #### vllm-omni Extension Parameters diff --git a/docs/user_guide/diffusion/attention_backends.md b/docs/user_guide/diffusion/attention_backends.md index 12446f3fe..50e02971f 100644 --- a/docs/user_guide/diffusion/attention_backends.md +++ b/docs/user_guide/diffusion/attention_backends.md @@ -83,7 +83,7 @@ When constructing `OmniDiffusionConfig` directly: from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, OmniDiffusionConfig config = OmniDiffusionConfig( - attention_config=AttentionConfig( + diffusion_attention_config=AttentionConfig( default=AttentionSpec(backend="FLASH_ATTN"), per_role={ "cross": AttentionSpec(backend="TORCH_SDPA"), diff --git a/docs/user_guide/diffusion/cpu_offload_diffusion.md b/docs/user_guide/diffusion/cpu_offload_diffusion.md index 765fb9535..d6fcd8944 100644 --- a/docs/user_guide/diffusion/cpu_offload_diffusion.md +++ b/docs/user_guide/diffusion/cpu_offload_diffusion.md @@ -33,7 +33,7 @@ m = Omni(model="Wan-AI/Wan2.2-T2V-A14B-Diffusers", enable_cpu_offload=True) **CLI:** ```bash -vllm-omni serve diffusion Wan-AI/Wan2.2-T2V-A14B-Diffusers --enable-cpu-offload +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --enable-cpu-offload ``` ### To Support a Model @@ -146,10 +146,10 @@ m = Omni(model="Wan-AI/Wan2.2-I2V-A14B-Diffusers", enable_layerwise_offload=True **CLI:** ```bash # Text-to-video -vllm-omni serve diffusion Wan-AI/Wan2.2-T2V-A14B-Diffusers --enable-layerwise-offload +vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --enable-layerwise-offload # Or image-to-video -vllm-omni serve diffusion Wan-AI/Wan2.2-I2V-A14B-Diffusers --enable-layerwise-offload +vllm serve Wan-AI/Wan2.2-I2V-A14B-Diffusers --omni --enable-layerwise-offload ``` ### To Support a Model diff --git a/docs/user_guide/examples/offline_inference/bagel.md b/docs/user_guide/examples/offline_inference/bagel.md index 0d3498b28..391c41b35 100644 --- a/docs/user_guide/examples/offline_inference/bagel.md +++ b/docs/user_guide/examples/offline_inference/bagel.md @@ -257,7 +257,7 @@ python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \ | :------- | :--- | :------ | :---------- | | `--deploy-config` | string | `None` | Path to deploy YAML (auto-detected if omitted) | | `--stage-configs-path` | string | `None` | [Deprecated] Legacy path to `stage_args` YAML; prefer `--deploy-config` | -| `--worker-backend` | choice | `process` | `process` or `ray` | +| `--worker-backend` | choice | `multi_process` | `multi_process` or `ray` | | `--ray-address` | string | `None` | Ray cluster address | | `--quantization` | string | `None` | Quantization method (e.g. `fp8`) | | `--log-stats` | flag | `False` | Enable statistics logging | diff --git a/docs/user_guide/examples/offline_inference/glm_image.md b/docs/user_guide/examples/offline_inference/glm_image.md index c6ac6e33f..09d927ff7 100644 --- a/docs/user_guide/examples/offline_inference/glm_image.md +++ b/docs/user_guide/examples/offline_inference/glm_image.md @@ -26,18 +26,22 @@ Stage 0 (AR Model) Stage 1 (Diffusion) ```python from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams if __name__ == "__main__": omni = Omni(model="zai-org/GLM-Image") outputs = omni.generate( "A photorealistic mountain landscape at sunset", - sampling_params={ - "height": 1024, - "width": 1024, - "num_inference_steps": 50, - "guidance_scale": 1.5, - "seed": 42, - }, + sampling_params_list=[ + omni.default_sampling_params_list[0], + OmniDiffusionSamplingParams( + height=1024, + width=1024, + num_inference_steps=50, + guidance_scale=1.5, + seed=42, + ), + ], ) outputs[0].request_output.images[0].save("output.png") ``` @@ -46,6 +50,7 @@ if __name__ == "__main__": ```python from vllm_omni.entrypoints.omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams if __name__ == "__main__": omni = Omni(model="zai-org/GLM-Image") @@ -56,13 +61,16 @@ if __name__ == "__main__": "image": "input.png", }, }, - sampling_params={ - "height": 1024, - "width": 1024, - "num_inference_steps": 50, - "guidance_scale": 1.5, - "seed": 42, - }, + sampling_params_list=[ + omni.default_sampling_params_list[0], + OmniDiffusionSamplingParams( + height=1024, + width=1024, + num_inference_steps=50, + guidance_scale=1.5, + seed=42, + ), + ], ) outputs[0].request_output.images[0].save("output.png") ``` diff --git a/docs/user_guide/examples/offline_inference/mimo_audio.md b/docs/user_guide/examples/offline_inference/mimo_audio.md index 1cba2f77d..a43d8e408 100644 --- a/docs/user_guide/examples/offline_inference/mimo_audio.md +++ b/docs/user_guide/examples/offline_inference/mimo_audio.md @@ -1,4 +1,4 @@ -# MiMo-Audio Offline Inference +# MiMo-Audio: Offline inference Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/mimo_audio>. diff --git a/docs/user_guide/examples/offline_inference/qwen2_5_omni.md b/docs/user_guide/examples/offline_inference/qwen2_5_omni.md index c54976b54..efd14bc1c 100644 --- a/docs/user_guide/examples/offline_inference/qwen2_5_omni.md +++ b/docs/user_guide/examples/offline_inference/qwen2_5_omni.md @@ -1,4 +1,4 @@ -# Qwen2.5-Omni +# Qwen2.5-Omni: Offline inference Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/qwen2_5_omni>. diff --git a/docs/user_guide/examples/offline_inference/qwen3_omni.md b/docs/user_guide/examples/offline_inference/qwen3_omni.md index 2d856f738..3781c1cc8 100644 --- a/docs/user_guide/examples/offline_inference/qwen3_omni.md +++ b/docs/user_guide/examples/offline_inference/qwen3_omni.md @@ -1,4 +1,4 @@ -# Qwen3-Omni +# Qwen3-Omni: Offline inference Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/offline_inference/qwen3_omni>. diff --git a/docs/user_guide/examples/online_serving/aura_omni.md b/docs/user_guide/examples/online_serving/aura_omni.md index 9c66d0df9..8b7f56ac5 100644 --- a/docs/user_guide/examples/online_serving/aura_omni.md +++ b/docs/user_guide/examples/online_serving/aura_omni.md @@ -1,4 +1,4 @@ -# AURA Omni Pipeline +# AURA Omni: Online serving `aura_omni` wires ASR, AURA, and Qwen3-TTS into one vLLM-Omni pipeline: diff --git a/docs/user_guide/examples/online_serving/mimo_audio.md b/docs/user_guide/examples/online_serving/mimo_audio.md index c8752f578..9db59f9e8 100644 --- a/docs/user_guide/examples/online_serving/mimo_audio.md +++ b/docs/user_guide/examples/online_serving/mimo_audio.md @@ -1,4 +1,4 @@ -# Online serving Example of vLLM-Omni for MiMo-Audio +# MiMo-Audio: Online serving Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/mimo_audio>. diff --git a/docs/user_guide/examples/online_serving/qwen2_5_omni.md b/docs/user_guide/examples/online_serving/qwen2_5_omni.md index b3a2c9f2a..d8c48a403 100644 --- a/docs/user_guide/examples/online_serving/qwen2_5_omni.md +++ b/docs/user_guide/examples/online_serving/qwen2_5_omni.md @@ -1,4 +1,4 @@ -# Qwen2.5-Omni +# Qwen2.5-Omni: Online serving Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/qwen2_5_omni>. diff --git a/docs/user_guide/examples/online_serving/qwen3_omni.md b/docs/user_guide/examples/online_serving/qwen3_omni.md index 1973b198b..ec509d597 100644 --- a/docs/user_guide/examples/online_serving/qwen3_omni.md +++ b/docs/user_guide/examples/online_serving/qwen3_omni.md @@ -1,4 +1,4 @@ -# Qwen3-Omni +# Qwen3-Omni: Online serving Source <https://github.com/vllm-project/vllm-omni/tree/main/examples/online_serving/qwen3_omni>. diff --git a/examples/model_display_names.yml b/examples/model_display_names.yml new file mode 100644 index 000000000..be3f8e381 --- /dev/null +++ b/examples/model_display_names.yml @@ -0,0 +1,12 @@ +# Canonical display names for model examples that have both offline and online pages. +# The docs generator combines these names with the serving mode for each page H1. +aura_omni: AURA Omni +covo_audio: Covo-Audio-Chat +dynin_omni: Dynin-Omni +lance: Lance +mimo_audio: MiMo-Audio +ming_flash_omni: Ming-flash-omni 2.0 +qwen2_5_omni: Qwen2.5-Omni +qwen3_omni: Qwen3-Omni +sensenova_u1: SenseNova-U1 +step_audio2: Step-Audio2 diff --git a/examples/offline_inference/aura_omni/README.md b/examples/offline_inference/aura_omni/README.md index 37fabf1ba..b73976a15 100644 --- a/examples/offline_inference/aura_omni/README.md +++ b/examples/offline_inference/aura_omni/README.md @@ -1,4 +1,4 @@ -# AURA Omni +# AURA Omni: Offline inference This example runs the native AURA Omni pipeline offline: diff --git a/examples/offline_inference/covo_audio/README.md b/examples/offline_inference/covo_audio/README.md index d13783d39..80a822fa2 100644 --- a/examples/offline_inference/covo_audio/README.md +++ b/examples/offline_inference/covo_audio/README.md @@ -1,4 +1,4 @@ -# Covo-Audio-Chat (Offline Inference) +# Covo-Audio-Chat: Offline inference ## Setup diff --git a/examples/offline_inference/dynin_omni/README.md b/examples/offline_inference/dynin_omni/README.md index 0b1cc22f2..19fa8c69e 100644 --- a/examples/offline_inference/dynin_omni/README.md +++ b/examples/offline_inference/dynin_omni/README.md @@ -1,4 +1,4 @@ -# Dynin-Omni Offline End2End Example +# Dynin-Omni: Offline inference This folder contains a unified offline inference entrypoint: diff --git a/examples/offline_inference/lance/README.md b/examples/offline_inference/lance/README.md index d029abdea..f3a422a3b 100644 --- a/examples/offline_inference/lance/README.md +++ b/examples/offline_inference/lance/README.md @@ -1,4 +1,4 @@ -# Lance (ByteDance) — offline inference +# Lance: Offline inference [Lance](https://huggingface.co/bytedance-research/Lance) is a 3B unified autoregressive + diffusion multimodal model on a Qwen2.5-VL backbone. It is diff --git a/examples/offline_inference/mimo_audio/README.md b/examples/offline_inference/mimo_audio/README.md index 5615dea51..60f433a4f 100644 --- a/examples/offline_inference/mimo_audio/README.md +++ b/examples/offline_inference/mimo_audio/README.md @@ -1,4 +1,4 @@ -# MiMo-Audio Offline Inference +# MiMo-Audio: Offline inference This directory contains an offline demo for running MiMo-Audio models with vLLM Omni. It builds task-specific inputs and generates WAV files or text outputs locally. diff --git a/examples/offline_inference/ming_flash_omni/README.md b/examples/offline_inference/ming_flash_omni/README.md index e60efe88b..ee7e93fd0 100644 --- a/examples/offline_inference/ming_flash_omni/README.md +++ b/examples/offline_inference/ming_flash_omni/README.md @@ -1,4 +1,4 @@ -# Ming-flash-omni 2.0 +# Ming-flash-omni 2.0: Offline inference [Ming-flash-omni-2.0](https://github.com/inclusionAI/Ming) is an omni-modal model supporting text, image, video, and audio understanding, with text and speech outputs. diff --git a/examples/offline_inference/qwen2_5_omni/README.md b/examples/offline_inference/qwen2_5_omni/README.md index 292045033..059c10226 100644 --- a/examples/offline_inference/qwen2_5_omni/README.md +++ b/examples/offline_inference/qwen2_5_omni/README.md @@ -1,4 +1,4 @@ -# Qwen2.5-Omni +# Qwen2.5-Omni: Offline inference ## Setup Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. diff --git a/examples/offline_inference/qwen3_omni/README.md b/examples/offline_inference/qwen3_omni/README.md index 0710faa13..2f4ee11cc 100644 --- a/examples/offline_inference/qwen3_omni/README.md +++ b/examples/offline_inference/qwen3_omni/README.md @@ -1,4 +1,4 @@ -# Qwen3-Omni +# Qwen3-Omni: Offline inference ## Setup Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. diff --git a/examples/offline_inference/sensenova_u1/README.md b/examples/offline_inference/sensenova_u1/README.md index 1e656c22d..9fdc23a00 100644 --- a/examples/offline_inference/sensenova_u1/README.md +++ b/examples/offline_inference/sensenova_u1/README.md @@ -1,4 +1,4 @@ -# SenseNova-U1-8B-MoT +# SenseNova-U1: Offline inference ## Architecture diff --git a/examples/offline_inference/step_audio2/README.md b/examples/offline_inference/step_audio2/README.md index 2ba69ceb5..84d0ecdde 100644 --- a/examples/offline_inference/step_audio2/README.md +++ b/examples/offline_inference/step_audio2/README.md @@ -1,4 +1,4 @@ -# Step-Audio2 Offline Inference Examples +# Step-Audio2: Offline inference This directory contains examples for running offline inference with Step-Audio2 using vLLM-Omni. diff --git a/examples/online_serving/aura_omni/README.md b/examples/online_serving/aura_omni/README.md index 6abd0ef47..5f246c336 100644 --- a/examples/online_serving/aura_omni/README.md +++ b/examples/online_serving/aura_omni/README.md @@ -1,4 +1,4 @@ -# AURA Omni Native Pipeline +# AURA Omni: Online serving `aura_omni` serves AURA as a native multi-stage vLLM-Omni pipeline: diff --git a/examples/online_serving/covo_audio/README.md b/examples/online_serving/covo_audio/README.md index 6894507ef..b6a4ad74a 100644 --- a/examples/online_serving/covo_audio/README.md +++ b/examples/online_serving/covo_audio/README.md @@ -1,4 +1,4 @@ -# Covo-Audio-Chat +# Covo-Audio-Chat: Online serving ## Installation diff --git a/examples/online_serving/dynin_omni/README.md b/examples/online_serving/dynin_omni/README.md index 50e2f3f80..6e78a5c1f 100644 --- a/examples/online_serving/dynin_omni/README.md +++ b/examples/online_serving/dynin_omni/README.md @@ -1,4 +1,4 @@ -# Dynin-Omni Online Serving Example +# Dynin-Omni: Online serving ## Installation diff --git a/examples/online_serving/lance/README.md b/examples/online_serving/lance/README.md index 5f2febce9..614f4990c 100644 --- a/examples/online_serving/lance/README.md +++ b/examples/online_serving/lance/README.md @@ -1,4 +1,4 @@ -# Lance — online serving +# Lance: Online serving OpenAI-compatible chat completions API for Lance, served by `vllm-omni`. diff --git a/examples/online_serving/mimo_audio/README.md b/examples/online_serving/mimo_audio/README.md index 9c1be7f21..7c5e2db0f 100644 --- a/examples/online_serving/mimo_audio/README.md +++ b/examples/online_serving/mimo_audio/README.md @@ -1,4 +1,4 @@ -# Online serving Example of vLLM-Omni for MiMo-Audio +# MiMo-Audio: Online serving ## 🛠️ Installation diff --git a/examples/online_serving/ming_flash_omni/README.md b/examples/online_serving/ming_flash_omni/README.md index 9810841db..377acc566 100644 --- a/examples/online_serving/ming_flash_omni/README.md +++ b/examples/online_serving/ming_flash_omni/README.md @@ -1,4 +1,4 @@ -# Ming-flash-omni 2.0 +# Ming-flash-omni 2.0: Online serving ## Installation diff --git a/examples/online_serving/qwen2_5_omni/README.md b/examples/online_serving/qwen2_5_omni/README.md index c52873206..48406003d 100644 --- a/examples/online_serving/qwen2_5_omni/README.md +++ b/examples/online_serving/qwen2_5_omni/README.md @@ -1,4 +1,4 @@ -# Qwen2.5-Omni +# Qwen2.5-Omni: Online serving ## 🛠️ Installation diff --git a/examples/online_serving/qwen3_omni/README.md b/examples/online_serving/qwen3_omni/README.md index 5e2e24a1f..3c3ad2b16 100644 --- a/examples/online_serving/qwen3_omni/README.md +++ b/examples/online_serving/qwen3_omni/README.md @@ -1,4 +1,4 @@ -# Qwen3-Omni +# Qwen3-Omni: Online serving ## 🛠️ Installation diff --git a/examples/online_serving/sensenova_u1/README.md b/examples/online_serving/sensenova_u1/README.md index d66a3506a..d529eedad 100644 --- a/examples/online_serving/sensenova_u1/README.md +++ b/examples/online_serving/sensenova_u1/README.md @@ -1,4 +1,4 @@ -# SenseNova-U1 Online Serving +# SenseNova-U1: Online serving ## Launch the Server diff --git a/examples/online_serving/step_audio2/README.md b/examples/online_serving/step_audio2/README.md index 3a04f4565..e93f58ccc 100644 --- a/examples/online_serving/step_audio2/README.md +++ b/examples/online_serving/step_audio2/README.md @@ -1,4 +1,4 @@ -# Step-Audio2 Online Serving +# Step-Audio2: Online serving This directory contains examples for running Step-Audio2 with vLLM-Omni's online serving API. diff --git a/tests/docs/test_generate_examples.py b/tests/docs/test_generate_examples.py new file mode 100644 index 000000000..0b29837a9 --- /dev/null +++ b/tests/docs/test_generate_examples.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import importlib.util +from pathlib import Path + +import pytest + +ROOT_DIR = Path(__file__).parents[2] +HOOK_PATH = ROOT_DIR / "docs/mkdocs/hooks/generate_examples.py" +SPEC = importlib.util.spec_from_file_location("generate_examples", HOOK_PATH) +assert SPEC and SPEC.loader +generate_examples = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generate_examples) + + +def test_model_example_titles_match_shared_display_names(): + for slug, display_name in generate_examples.MODEL_DISPLAY_NAMES.items(): + for category, mode_title in generate_examples.SERVING_MODE_TITLES.items(): + example_path = ROOT_DIR / "examples" / category / slug + example = generate_examples.Example(example_path, category) + + assert example.title == f"{display_name}: {mode_title}" + assert example.nav_title == display_name + + +def test_model_example_title_mismatch_is_rejected(tmp_path): + example_path = tmp_path / "mimo_audio" + example_path.mkdir() + (example_path / "README.md").write_text("# A different title\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Model example title mismatch"): + generate_examples.Example(example_path, "offline_inference") + + +def test_model_display_names_require_string_mapping(tmp_path, monkeypatch): + display_names_path = tmp_path / "model_display_names.yml" + display_names_path.write_text("- not a mapping\n", encoding="utf-8") + monkeypatch.setattr(generate_examples, "MODEL_DISPLAY_NAMES_FILE", display_names_path) + + with pytest.raises(ValueError, match="MODEL_DISPLAY_NAMES_FILE"): + generate_examples.load_model_display_names() From 2c0d8399acc1a0eb44c7bbec755fa3623058213d Mon Sep 17 00:00:00 2001 From: akshatvishu <akshatnayak197@gmail.com> Date: Tue, 14 Jul 2026 10:05:44 +0530 Subject: [PATCH 077/101] [Bugfix] Restore Qwen3-Omni speaker metadata during non-async handoff (#5086) Signed-off-by: akshatvishu <akshatnayak197@gmail.com> --- .../test_qwen3_omni_streaming_helpers.py | 23 +++++++++++++++++++ .../stage_input_processors/qwen3_omni.py | 23 +++++++++++-------- 2 files changed, 37 insertions(+), 9 deletions(-) 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 811620608..8a6dfbe94 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 @@ -251,6 +251,29 @@ def test_thinker2talker_full_payload_packs_complete_tensors() -> None: assert payload["hidden_states"]["output"].shape[0] == 2 +def test_thinker2talker_token_only_preserves_voice_metadata() -> None: + source_outputs = [ + SimpleNamespace( + request_id="req-1", + prompt_token_ids=[1, 2], + outputs=[SimpleNamespace(cumulative_token_ids=[3])], + ) + ] + prompt = { + "additional_information": { + "speaker": ["ethan"], + "language": ["English"], + } + } + + [talker_prompt] = q3.thinker2talker_token_only(source_outputs, prompt) + + assert talker_prompt["additional_information"] == { + "speaker": ["ethan"], + "language": ["English"], + } + + def test_accumulator_replaces_keys_in_replace_set() -> None: """REPLACE-key semantics: subsequent emissions of the same key replace, not append.""" from vllm_omni.worker.omni_connector_model_runner_mixin import OmniConnectorModelRunnerMixin diff --git a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py index 6152bb82b..edde6972d 100644 --- a/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py +++ b/vllm_omni/model_executor/stage_input_processors/qwen3_omni.py @@ -24,7 +24,9 @@ from vllm_omni.data_entry_keys import ( from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.inputs.data import OmniTokensPrompt from vllm_omni.model_executor.stage_input_processors.tts_utils import ( + extract_language_from_prompt, extract_language_from_request, + extract_speaker_from_prompt, extract_speaker_from_request, ) @@ -622,15 +624,11 @@ def thinker2talker_token_only( After the communication-layer refactor, this function only allocates a placeholder ``prompt_token_ids`` of the correct length so the scheduler can - reserve KV-cache slots. It does **not** forward bulk tensors or voice - metadata. + reserve KV-cache slots. It does **not** forward bulk tensors. - Bulk talker conditioning (embed / hidden_states / ids) and per-request - voice metadata (``speaker`` / ``language``) are packed by Stage-0 - ``thinker2talker_full_payload`` via ``extract_speaker_from_request`` / - ``extract_language_from_request``, sent over the connector, and merged into - the Talker worker's ``model_intermediate_buffer`` on recv. The orchestrator - path therefore sets ``additional_information=None``. + Bulk talker conditioning is sent through the connector. Speaker and + language are also copied from the original prompt so they survive when + Stage-0 request metadata is unavailable to the connector payload. ``prompt`` / ``requires_multimodal_data`` are kept for call-site signature compatibility with other orchestrator input processors; they are unused. @@ -655,10 +653,17 @@ def thinker2talker_token_only( thinker_input_ids = prompt_token_ids info_for_len = {"ids": {"all": thinker_sequences, "prompt": thinker_input_ids}} prompt_len = _compute_talker_prompt_ids_length(info_for_len, device="cpu") + # Keep this fallback until the connector reliably preserves voice metadata. + additional_information = to_dict( + OmniPayloadStruct( + speaker=extract_speaker_from_prompt(prompt, index=i), + language=extract_language_from_prompt(prompt, index=i), + ) + ) talker_inputs.append( OmniTokensPrompt( prompt_token_ids=[0] * prompt_len, - additional_information=None, + additional_information=additional_information or None, multi_modal_data=None, mm_processor_kwargs=None, ) From b20ebc8f9ba405f25cb941969804645b87d998be Mon Sep 17 00:00:00 2001 From: Weiming Liao <fayespica@outlook.com> Date: Tue, 14 Jul 2026 15:47:02 +0800 Subject: [PATCH 078/101] [NPU] Upgrade CI IMAGE to v0.25.0 (#5095) Signed-off-by: Weiming Liao <liaowm5@gmail.com> --- docker/Dockerfile.npu | 2 +- docker/Dockerfile.npu.a3 | 2 +- docker/Dockerfile.npu.ci | 2 +- docker/Dockerfile.npu.ci.a3 | 2 +- docs/getting_started/installation/npu/npu.inc.md | 14 +++++++------- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu index 29ea95cc3..642b89882 100644 --- a/docker/Dockerfile.npu +++ b/docker/Dockerfile.npu @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.24.0 +ARG VLLM_ASCEND_TAG=v0.25.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm diff --git a/docker/Dockerfile.npu.a3 b/docker/Dockerfile.npu.a3 index 90036d345..4fe1d56fe 100644 --- a/docker/Dockerfile.npu.a3 +++ b/docker/Dockerfile.npu.a3 @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.24.0-a3 +ARG VLLM_ASCEND_TAG=v0.25.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} # WORKDIR /vllm-workspace/vllm diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci index f8746d2c8..f5c3cdfaf 100644 --- a/docker/Dockerfile.npu.ci +++ b/docker/Dockerfile.npu.ci @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.24.0 +ARG VLLM_ASCEND_TAG=v0.25.0 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni diff --git a/docker/Dockerfile.npu.ci.a3 b/docker/Dockerfile.npu.ci.a3 index 5e5848d39..6a8ac4678 100644 --- a/docker/Dockerfile.npu.ci.a3 +++ b/docker/Dockerfile.npu.ci.a3 @@ -1,5 +1,5 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.24.0-a3 +ARG VLLM_ASCEND_TAG=v0.25.0-a3 FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} ARG APP_DIR=/vllm-workspace/vllm-omni diff --git a/docs/getting_started/installation/npu/npu.inc.md b/docs/getting_started/installation/npu/npu.inc.md index 87d479b49..7381f5b10 100644 --- a/docs/getting_started/installation/npu/npu.inc.md +++ b/docs/getting_started/installation/npu/npu.inc.md @@ -9,8 +9,8 @@ The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built ```bash # vLLM-Ascend image -# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.24.0 -# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.24.0-a3 +# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.25.0 +# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.25.0-a3 docker run --rm \ --name vllm-omni-npu \ --shm-size=1g \ @@ -28,11 +28,11 @@ docker run --rm \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v /root/.cache:/root/.cache \ -p 8000:8000 \ - -it quay.io/atlas-ci/vllm-ascend:0.24.0-a3 bash + -it quay.io/atlas-ci/vllm-ascend:0.25.0-a3 bash # Inside the container, install vLLM-Omni from source cd /vllm-workspace -git clone -b v0.24.0 https://github.com/vllm-project/vllm-omni.git +git clone -b v0.25.0 https://github.com/vllm-project/vllm-omni.git cd vllm-omni pip install -v -e . --no-build-isolation # or VLLM_OMNI_TARGET_DEVICE=npu pip install -v -e . @@ -84,8 +84,8 @@ Supported images as following. Here's an example deployment command that has been verified on 4 x NPUs: ```bash -# Atlas A2: quay.io/ascend/vllm-omni:v0.24.0 -# Atlas A3: quay.io/ascend/vllm-omni:v0.24.0-a3 +# Atlas A2: quay.io/ascend/vllm-omni:v0.25.0 +# Atlas A3: quay.io/ascend/vllm-omni:v0.25.0-a3 docker run --rm \ --name vllm-omni-a3 \ --shm-size=4g \ @@ -110,7 +110,7 @@ docker run --rm \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v ~/.cache:/root/.cache \ -p 8091:8091 \ - -it quay.io/ascend/vllm-omni:v0.24.0-a3 bash + -it quay.io/ascend/vllm-omni:v0.25.0-a3 bash ``` !!! tip From e0570a08596fb9a85d392531bc07e509c38867dc Mon Sep 17 00:00:00 2001 From: Diya Peng <50388704+dpeng123@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:48:48 -0700 Subject: [PATCH 079/101] [Quantization] Add BitsAndBytes W4 online quantization for diffusion transformer (#5037) Signed-off-by: diyapeng <50388704+diyapeng@users.noreply.github.com> Co-authored-by: diyapeng <50388704+diyapeng@users.noreply.github.com> --- docs/.nav.yml | 1 + docs/user_guide/quantization/bitsandbytes.md | 120 ++++++++ .../text_to_image/text_to_image.py | 5 +- .../quantization/test_bitsandbytes_config.py | 258 ++++++++++++++++++ .../test_async_omni_diffusion_config.py | 24 ++ vllm_omni/quantization/bitsandbytes_config.py | 176 ++++++++++++ vllm_omni/quantization/factory.py | 8 + 7 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 docs/user_guide/quantization/bitsandbytes.md create mode 100644 tests/diffusion/quantization/test_bitsandbytes_config.py create mode 100644 vllm_omni/quantization/bitsandbytes_config.py diff --git a/docs/.nav.yml b/docs/.nav.yml index f94907a67..6bf161b1b 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -80,6 +80,7 @@ nav: - Quantized KV Cache: user_guide/quantization/quantized_kvcache.md - FP8 W8A8: user_guide/quantization/fp8.md - Int8 W8A8: user_guide/quantization/int8.md + - BitsAndBytes W4: user_guide/quantization/bitsandbytes.md - MXFP8 W8A8: user_guide/quantization/mxfp8.md - MXFP4 W4A4: user_guide/quantization/mxfp4.md - ModelOpt: user_guide/quantization/modelopt.md diff --git a/docs/user_guide/quantization/bitsandbytes.md b/docs/user_guide/quantization/bitsandbytes.md new file mode 100644 index 000000000..37ccd2471 --- /dev/null +++ b/docs/user_guide/quantization/bitsandbytes.md @@ -0,0 +1,120 @@ +# BitsAndBytes W4 Quantization + +## Overview + +BitsAndBytes 4-bit quantization supports weight-only NF4/FP4 diffusion transformer +inference on CUDA GPUs. It quantizes BF16/FP16 weights at load time; activations +stay in BF16/FP16. No pre-quantized checkpoint is required. + +This is an online (dynamic) path: load the normal HuggingFace checkpoint and +quantize during model loading via the `bitsandbytes` CUDA kernels. + +## Hardware Support + +| Device | Support | +|--------|---------| +| NVIDIA CUDA GPU (SM 75+) | ✅ | +| NVIDIA Blackwell GPU (SM 100+) | ✅ | +| NVIDIA Ada/Hopper GPU (SM 89+) | ✅ | +| NVIDIA Ampere GPU (SM 80+) | ✅ | +| AMD ROCm | ❌ | +| Intel XPU | ❌ | +| Ascend NPU | ❌ | + +Legend: `✅` supported, `❌` unsupported. Non-CUDA platforms raise in +`get_quant_method()`; CUDA requires compute capability 7.5+ (`SM 75+`). + +Requires the optional `bitsandbytes` package (`pip install bitsandbytes`). + +## Model Type Support + +### Diffusion Model (Qwen-Image, Wan2.2) + +| Model | HF models | CUDA | Mode | Recommendation | +|-------|-----------|:----:|------|----------------| +| Z-Image | `Tongyi-MAI/Z-Image-Turbo` | Yes | Online W4 weight-only | All heavy linear layers; sensitive embedders stay BF16 | +| Qwen-Image | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Not validated | Online W4 weight-only | Compare vs BF16 before enabling | +| Wan2.2 | Wan2.2 diffusion pipelines | Not validated | Online W4 weight-only | Validate before enabling in docs | + +Other diffusion models may work if their transformer uses supported linear +layers, but they are not validated in this guide. + +### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS) + +| Model | Scope | Status | Notes | +|-------|-------|--------|-------| +| Qwen3-Omni | Thinker language-model stage | Not validated | Prefer checkpoint-supported ModelOpt FP8 or AutoRound paths | +| Qwen3-TTS | TTS language-model stage | Not validated | No BitsAndBytes TTS stage support is documented | + +### Multi-Stage Diffusion Model (BAGEL, GLM-Image) + +| Model | Scope | Status | Notes | +|-------|-------|--------|-------| +| BAGEL | Stage-specific transformer or DiT module | Not validated | Requires explicit stage routing | +| GLM-Image | Stage-specific transformer or DiT module | Not validated | Requires quality comparison with BF16 | + +## Configuration + +Python API: + +```python +from vllm_omni import Omni +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +omni = Omni(model="Tongyi-MAI/Z-Image-Turbo", quantization="bitsandbytes") + +omni_with_skips = Omni( + model="Tongyi-MAI/Z-Image-Turbo", + quantization_config={ + "method": "bitsandbytes", + "ignored_layers": ["to_out"], + }, +) + +outputs = omni.generate( + "A cup of coffee on the table", + OmniDiffusionSamplingParams(num_inference_steps=50), +) +``` + +CLI: + +```bash +python text_to_image.py --model Tongyi-MAI/Z-Image-Turbo --quantization bitsandbytes +python text_to_image.py --model Tongyi-MAI/Z-Image-Turbo --quantization bitsandbytes --ignored-layers "to_out" +vllm serve Tongyi-MAI/Z-Image-Turbo --omni --quantization bitsandbytes +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `method` | str | - | Quantization method (`"bitsandbytes"`) | +| `quant_type` | str | `"nf4"` | 4-bit data type: `"nf4"` (recommended) or `"fp4"` | +| `compress_statistics` | bool | `True` | Double-quantize block scaling statistics for better accuracy | +| `ignored_layers` | list[str] | `[]` | Layer name patterns to keep in BF16/FP16 | + +## Validation and Notes + +On Z-Image-Turbo (single GPU, 1024×1024, 50 steps), BitsAndBytes W4 typically +reduces peak VRAM from roughly 24.5 GiB (BF16) to roughly 17 GiB. Compare output +quality against a BF16 baseline before enabling on new models. + +Multi-GPU tensor parallelism (`tensor_parallel_size` > 1) is not validated for +BitsAndBytes in diffusion models; each rank quantizes its own weight shard +independently. + +If quality regresses, use `ignored_layers` to keep sensitive projections in BF16 +(for example `to_out` or `w2`). + +```python +omni = Omni( + model="Tongyi-MAI/Z-Image-Turbo", + quantization="bitsandbytes", + cache_backend="tea_cache", + cache_config={"rel_l1_thresh": 0.2}, +) +``` + +Only add a new model to the supported table after comparing the BitsAndBytes +output against a BF16 baseline and documenting any required `ignored_layers`. diff --git a/examples/offline_inference/text_to_image/text_to_image.py b/examples/offline_inference/text_to_image/text_to_image.py index 374356ef4..61dd29261 100644 --- a/examples/offline_inference/text_to_image/text_to_image.py +++ b/examples/offline_inference/text_to_image/text_to_image.py @@ -202,9 +202,10 @@ def parse_args() -> argparse.Namespace: "--quantization", type=str, default=None, - choices=["fp8", "int8"], + choices=["fp8", "int8", "bitsandbytes"], help="Quantization method for the transformer. " - "Options: 'fp8' (FP8 W8A8 on Ada/Hopper, weight-only on older GPUs), 'int8' (Int8 W8A8). " + "Options: 'fp8' (FP8 W8A8 on Ada/Hopper, weight-only on older GPUs), " + "'int8' (Int8 W8A8), 'bitsandbytes' (NF4 4-bit weight-only, CUDA SM 75+). " "Default: None (no quantization, uses BF16).", ) parser.add_argument( diff --git a/tests/diffusion/quantization/test_bitsandbytes_config.py b/tests/diffusion/quantization/test_bitsandbytes_config.py new file mode 100644 index 000000000..8da2cd521 --- /dev/null +++ b/tests/diffusion/quantization/test_bitsandbytes_config.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for BitsAndBytes quantization config.""" + +import importlib.util +import sys +import types + +import pytest +import torch +from pytest_mock import MockerFixture +from torch.nn import Module, Parameter +from vllm.model_executor.layers.linear import LinearBase, UnquantizedLinearMethod + +from vllm_omni.platforms import current_omni_platform +from vllm_omni.quantization import build_quant_config +from vllm_omni.quantization.factory import SUPPORTED_QUANTIZATION_METHODS + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] + +cuda_available = pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="GPU platform not available.") + +bitsandbytes_available = pytest.mark.skipif( + importlib.util.find_spec("bitsandbytes") is None, + reason="bitsandbytes package not installed", +) + + +def _ensure_bitsandbytes_importable(monkeypatch: pytest.MonkeyPatch) -> None: + """Inject a stub bitsandbytes package when the optional dep is absent. + + ``mocker.patch("bitsandbytes...")`` imports the target module; without a + stub, core-model CI without bitsandbytes raises ModuleNotFoundError. + """ + if importlib.util.find_spec("bitsandbytes") is not None: + return + bnb = types.ModuleType("bitsandbytes") + bnb_functional = types.ModuleType("bitsandbytes.functional") + bnb.functional = bnb_functional + monkeypatch.setitem(sys.modules, "bitsandbytes", bnb) + monkeypatch.setitem(sys.modules, "bitsandbytes.functional", bnb_functional) + + +def test_bitsandbytes_config_creation(): + """Test that BitsAndBytes config can be created.""" + config = build_quant_config("bitsandbytes") + assert config is not None + assert config.get_name() == "bitsandbytes" + + +def test_bitsandbytes_config_with_custom_params(): + """Test BitsAndBytes config with custom parameters.""" + config = build_quant_config( + "bitsandbytes", + quant_type="fp4", + compress_statistics=False, + ignored_layers=["to_out"], + ) + assert config is not None + assert config.quant_type == "fp4" + assert config.compress_statistics is False + assert "to_out" in config.ignored_layers + + +def test_supported_methods(): + """Test that supported methods list includes bitsandbytes.""" + assert "bitsandbytes" in SUPPORTED_QUANTIZATION_METHODS + + +def test_quantization_integration(): + """Test end-to-end quantization flow through OmniDiffusionConfig.""" + from vllm_omni.diffusion.data import OmniDiffusionConfig + + config = OmniDiffusionConfig(model="test", quantization_config="bitsandbytes") + assert config.quantization_config is not None + assert config.quantization_config.get_name() == "bitsandbytes" + + config2 = OmniDiffusionConfig( + model="test", + quantization_config={ + "method": "bitsandbytes", + "quant_type": "nf4", + "compress_statistics": True, + }, + ) + assert config2.quantization_config is not None + assert config2.quantization_config.get_name() == "bitsandbytes" + assert config2.quantization_config.quant_type == "nf4" + assert config2.quantization_config.compress_statistics is True + + +def test_quantization_dict_not_mutated(): + """Test that passing a dict to quantization_config doesn't mutate it.""" + from vllm_omni.diffusion.data import OmniDiffusionConfig + + original_dict = {"method": "bitsandbytes", "quant_type": "nf4"} + dict_copy = original_dict.copy() + + OmniDiffusionConfig(model="test", quantization_config=original_dict) + + assert original_dict == dict_copy + + +def test_get_quant_method(mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + """Test get_quant_method routing for CUDA.""" + from vllm_omni.quantization.bitsandbytes_config import BnBOnlineLinearMethod + + config = build_quant_config("bitsandbytes") + + def _fake_init(self, quant_config): + pass + + layer = mocker.Mock(spec=LinearBase) + mocker.patch.object(BnBOnlineLinearMethod, "__init__", _fake_init) + + prefix = "test_layer" + + monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: True) + method = config.get_quant_method(layer, prefix) + assert isinstance(method, BnBOnlineLinearMethod) + + config.ignored_layers = [prefix] + method = config.get_quant_method(layer, prefix) + assert isinstance(method, UnquantizedLinearMethod) + + +class TestBnBOnlineLinearMethod: + @pytest.fixture + def mock_quant_config(self, mocker): + config = mocker.Mock() + config.quant_type = "nf4" + config.compress_statistics = True + return config + + @pytest.fixture + def mock_deps(self, mocker, monkeypatch: pytest.MonkeyPatch): + _ensure_bitsandbytes_importable(monkeypatch) + mock_qweight = torch.ones((64, 32), dtype=torch.uint8) + mock_quant_state = mocker.Mock() + mock_quant = mocker.patch( + "bitsandbytes.functional.quantize_4bit", + return_value=(mock_qweight, mock_quant_state), + create=True, + ) + # x is (2, 16, 32) -> flattened (32, 32); matmul must return (32, 64) + # so apply can reshape back to (2, 16, 64). + mock_matmul = mocker.patch( + "bitsandbytes.matmul_4bit", + return_value=torch.randn(32, 64), + create=True, + ) + return { + "quant": mock_quant, + "matmul": mock_matmul, + "mock_qweight": mock_qweight, + "mock_quant_state": mock_quant_state, + } + + def test_process_weights_after_loading(self, mock_deps, mock_quant_config, mocker): + from vllm_omni.quantization.bitsandbytes_config import BnBOnlineLinearMethod + + method = BnBOnlineLinearMethod(mock_quant_config) + layer = Module() + layer.weight = Parameter(torch.randn(64, 32)) + mocker.patch.object(torch.Tensor, "cuda", return_value=layer.weight.data) + + method.process_weights_after_loading(layer) + + mock_deps["quant"].assert_called_once() + assert torch.equal(layer.weight.data, mock_deps["mock_qweight"]) + assert layer.quant_state is mock_deps["mock_quant_state"] + assert layer.bnb_shape == (64, 32) + + def test_apply(self, mock_deps, mock_quant_config): + from vllm_omni.quantization.bitsandbytes_config import BnBOnlineLinearMethod + + method = BnBOnlineLinearMethod(mock_quant_config) + layer = Module() + layer.weight = Parameter(torch.ones((64, 32), dtype=torch.uint8), requires_grad=False) + layer.quant_state = mock_deps["mock_quant_state"] + + x = torch.randn(2, 16, 32, dtype=torch.float16) + bias = torch.randn(64) + output = method.apply(layer, x, bias) + + mock_deps["matmul"].assert_called_once() + assert output.shape == (2, 16, 64) + assert output.dtype == torch.float16 + + +@pytest.fixture +def quant_config(): + from vllm_omni.quantization.bitsandbytes_config import DiffusionBitsAndBytesConfig + + return DiffusionBitsAndBytesConfig( + quant_type="nf4", + compress_statistics=True, + ) + + +@cuda_available +@bitsandbytes_available +class TestCudaBnBSmoke: + """Smoke tests using real bitsandbytes CUDA kernels.""" + + @pytest.fixture + def real_layer(self): + layer = torch.nn.Module() + layer.weight = torch.nn.Parameter( + torch.randn(128, 64, dtype=torch.float16, device="cuda"), + requires_grad=False, + ) + layer.logical_widths = [128] + layer.input_size_per_partition = 64 + layer.output_size_per_partition = 128 + layer.orig_dtype = torch.float16 + return layer + + def test_real_cuda_quantize_4bit_shape_contract(self, quant_config): + import bitsandbytes.functional as bnb_F + + weight = torch.randn(128, 64, dtype=torch.float16, device="cuda") + qweight, quant_state = bnb_F.quantize_4bit( + weight, + quant_type=quant_config.quant_type, + compress_statistics=quant_config.compress_statistics, + ) + + assert qweight.dtype == torch.uint8 + assert quant_state is not None + + def test_real_cuda_online_process_weights_after_loading(self, quant_config, real_layer): + from vllm_omni.quantization.bitsandbytes_config import BnBOnlineLinearMethod + + method = BnBOnlineLinearMethod(quant_config) + method.process_weights_after_loading(real_layer) + + assert real_layer.weight.dtype == torch.uint8 + assert hasattr(real_layer, "quant_state") + assert real_layer.bnb_shape == (128, 64) + + def test_real_cuda_bnb_apply_forward(self, quant_config): + from vllm_omni.quantization.bitsandbytes_config import BnBOnlineLinearMethod + + method = BnBOnlineLinearMethod(quant_config) + + layer = torch.nn.Module() + layer.weight = torch.nn.Parameter( + torch.randn(128, 64, dtype=torch.float16, device="cuda"), + requires_grad=False, + ) + method.process_weights_after_loading(layer) + + x = torch.randn(2, 16, 64, dtype=torch.float16, device="cuda") + output = method.apply(layer, x) + + assert output.shape == (2, 16, 128) + assert output.dtype == torch.float16 diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index 3d29a4332..ba132dbd2 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -371,3 +371,27 @@ def test_default_stage_config_includes_quantization_config(): stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg({"quantization_config": quantization_config})[0] assert stage_cfg["engine_args"]["quantization_config"] == quantization_config + + +def test_resolve_stage_configs_injects_quantization_config_into_diffusion_stage(mocker): + fake_diffusion_stage = SimpleNamespace( + stage_type="diffusion", + engine_args=SimpleNamespace(quantization_config=None), + ) + mocker.patch( + "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", + return_value=("dummy.yaml", [fake_diffusion_stage], None), + ) + + engine = AsyncOmniEngine.__new__(AsyncOmniEngine) + engine._strip_single_engine_args = lambda kwargs: kwargs + + _, stage_configs = engine._resolve_stage_configs( + "dummy-model", + { + "stage_configs_path": "dummy.yaml", + "quantization_config": {"method": "bitsandbytes"}, + }, + ) + + assert stage_configs[0].engine_args.quantization_config == {"method": "bitsandbytes"} diff --git a/vllm_omni/quantization/bitsandbytes_config.py b/vllm_omni/quantization/bitsandbytes_config.py new file mode 100644 index 000000000..7d8e7a684 --- /dev/null +++ b/vllm_omni/quantization/bitsandbytes_config.py @@ -0,0 +1,176 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""BitsAndBytes 4-bit quantization config for diffusion transformers. + +Supports online (dynamic) NF4/FP4 weight-only quantization from BF16/FP16 +checkpoints on CUDA GPUs. +""" + +from typing import TYPE_CHECKING, Any, Optional + +import torch +from torch.nn import Module +from vllm.model_executor.layers.linear import ( + LinearBase, + LinearMethodBase, + UnquantizedLinearMethod, +) +from vllm.model_executor.layers.quantization import QuantizationMethods +from vllm.model_executor.layers.quantization.base_config import ( + QuantizationConfig, + QuantizeMethodBase, +) +from vllm.model_executor.layers.quantization.fp8 import _copy_missing_attrs +from vllm.model_executor.layers.quantization.utils.quant_utils import ( + is_layer_skipped, +) +from vllm.model_executor.model_loader.weight_utils import initialize_single_dummy_weight +from vllm.model_executor.parameter import ModelWeightParameter +from vllm.model_executor.utils import replace_parameter + +from vllm_omni.platforms import current_omni_platform +from vllm_omni.quantization.int8_config import LazyWeightMixin + +if TYPE_CHECKING: + from vllm.model_executor.models.utils import WeightsMapper + + +class DiffusionBitsAndBytesConfig(QuantizationConfig): + """BitsAndBytes 4-bit weight-only config for diffusion transformers. + + Supports online (dynamic) quantization from BF16/FP16 checkpoints. + Works on CUDA GPUs with the optional ``bitsandbytes`` package installed. + """ + + def __init__( + self, + quant_type: str = "nf4", + compress_statistics: bool = True, + ignored_layers: list[str] | None = None, + ) -> None: + super().__init__() + + if quant_type not in ("nf4", "fp4"): + raise ValueError(f"Unsupported quant_type {quant_type!r}; expected 'nf4' or 'fp4'") + self.quant_type = quant_type + self.compress_statistics = compress_statistics + self.ignored_layers = ignored_layers or [] + + @classmethod + def get_name(cls) -> QuantizationMethods: + return "bitsandbytes" + + @classmethod + def get_supported_act_dtypes(cls) -> list[torch.dtype]: + return [torch.bfloat16, torch.float16] + + @classmethod + def get_min_capability(cls) -> int: + return 75 + + @classmethod + def get_config_filenames(cls) -> list[str]: + return [] + + def apply_vllm_mapper(self, hf_to_vllm_mapper: "WeightsMapper"): + if self.ignored_layers is not None: + self.ignored_layers = hf_to_vllm_mapper.apply_list(self.ignored_layers) + + @classmethod + def from_config(cls, config: dict[str, Any]) -> "DiffusionBitsAndBytesConfig": + quant_type = cls.get_from_keys_or(config, ["quant_type"], "nf4") + compress_statistics = cls.get_from_keys_or(config, ["compress_statistics"], True) + ignored_layers = cls.get_from_keys_or(config, ["ignored_layers"], None) + + if not ignored_layers: + ignored_layers = cls.get_from_keys_or(config, ["modules_to_not_convert"], None) + return cls( + quant_type=quant_type, + compress_statistics=compress_statistics, + ignored_layers=ignored_layers, + ) + + def get_quant_method( + self, + layer: torch.nn.Module, + prefix: str, + ) -> Optional["QuantizeMethodBase"]: + if isinstance(layer, LinearBase): + if is_layer_skipped( + prefix=prefix, + ignored_layers=self.ignored_layers, + fused_mapping=self.packed_modules_mapping, + ): + return UnquantizedLinearMethod() + if current_omni_platform.is_cuda(): + return BnBOnlineLinearMethod(self) + raise NotImplementedError("BitsAndBytes online quantization is only supported on CUDA.") + return None + + +class BnBOnlineLinearMethod(LazyWeightMixin, LinearMethodBase): + """Online BitsAndBytes 4-bit linear method. + + Loads BF16/FP16 checkpoint weights and quantizes them during loading. + """ + + def __init__(self, quant_config: DiffusionBitsAndBytesConfig): + self.quant_config = quant_config + + def process_weights_after_loading(self, layer: Module) -> None: + if getattr(layer, "_already_called_process_weights_after_loading", False): + return + + if layer.weight.device == torch.device("meta"): + weight = ModelWeightParameter( + data=torch.empty_like(layer.weight, device=layer._load_device), + input_dim=1, + output_dim=0, + weight_loader=layer.weight.weight_loader, + ) + _copy_missing_attrs(layer.weight, weight) + layer.register_parameter("weight", weight) + initialize_single_dummy_weight(layer.weight) + + import bitsandbytes.functional as bnb_F + + weight = layer.weight.data.contiguous() + if not weight.is_cuda: + weight = weight.cuda() + + original_shape = tuple(weight.shape) + qweight, quant_state = bnb_F.quantize_4bit( + weight, + quant_type=self.quant_config.quant_type, + compress_statistics=self.quant_config.compress_statistics, + ) + + replace_parameter( + layer, + "weight", + torch.nn.Parameter(qweight, requires_grad=False), + ) + layer.quant_state = quant_state + layer.bnb_shape = original_shape + + layer._already_called_process_weights_after_loading = True + + def apply( + self, + layer: torch.nn.Module, + x: torch.Tensor, + bias: torch.Tensor | None = None, + ) -> torch.Tensor: + import bitsandbytes as bnb + + ori_shape = x.shape + ori_dtype = x.dtype + x_2d = x.reshape(-1, ori_shape[-1]) + + out = bnb.matmul_4bit( + x_2d, + layer.weight.t(), + quant_state=layer.quant_state, + bias=bias, + ) + return out.reshape(*ori_shape[:-1], -1).to(ori_dtype) diff --git a/vllm_omni/quantization/factory.py b/vllm_omni/quantization/factory.py index 2129d8f04..b5d0a8ad7 100644 --- a/vllm_omni/quantization/factory.py +++ b/vllm_omni/quantization/factory.py @@ -88,6 +88,13 @@ def _build_int8(**kw: Any) -> QuantizationConfig: return DiffusionInt8Config(**kw) +def _build_bitsandbytes(**kw: Any) -> QuantizationConfig: + """Lazy import for BitsAndBytes 4-bit diffusion config (CUDA only).""" + from .bitsandbytes_config import DiffusionBitsAndBytesConfig + + return DiffusionBitsAndBytesConfig(**kw) + + def _build_mxfp8(**kw: Any) -> QuantizationConfig: """Lazy import for W8A8 MXFP8 diffusion config (NPU only).""" from .mxfp8_config import DiffusionMXFP8Config @@ -134,6 +141,7 @@ def _build_inc(**kw: Any) -> QuantizationConfig: _OVERRIDES: dict[str, Callable[..., QuantizationConfig]] = { "int8": _build_int8, + "bitsandbytes": _build_bitsandbytes, "mxfp8": _build_mxfp8, "mxfp4": _build_mxfp4, "mxfp4_dualscale": _build_mxfp4_dualscale, From b1809985ef30a8b60a0c1d3d6b6e8b4aabe529c5 Mon Sep 17 00:00:00 2001 From: Zhang Jian <jianmusings@gmail.com> Date: Tue, 14 Jul 2026 16:19:44 +0800 Subject: [PATCH 080/101] [recipe] Add cosmos3-nano recipe for npu (1xA3) (#4978) Signed-off-by: Zhang Jian <jianmusings@gmail.com> Signed-off-by: Chendi Xue <chendi.xue@intel.com> Co-authored-by: Chendi Xue <chendi.xue@intel.com> --- recipes/cosmos3/Cosmos3-Nano.md | 182 ++++++++++++++++++ .../diffusion/models/cosmos3/guardrails.py | 29 ++- 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/recipes/cosmos3/Cosmos3-Nano.md b/recipes/cosmos3/Cosmos3-Nano.md index e58a28623..c3282ba51 100644 --- a/recipes/cosmos3/Cosmos3-Nano.md +++ b/recipes/cosmos3/Cosmos3-Nano.md @@ -422,3 +422,185 @@ ffprobe -v error -show_entries stream=codec_type,nb_frames,width,height cosmos3_ `generate_sound`/`sound_duration`, `guardrails`, `action_*`, ...) are declared once in `vllm_omni/model_extras/cosmos3.py` and forwarded through `--extra-body`; unknown keys for the model are dropped. + +## NPU + +### 1x Ascend 910B / 910C (Atlas A2 / A3) — Online serving + +#### Environment + +- OS: Linux (aarch64) +- Python: 3.12+ +- Driver / runtime: CANN 8.5.1 + NNAL + Ascend 910B / 910C +- vLLM version: match the repository requirements from your current checkout +- vLLM-Ascend version: match the repository requirements from your current checkout +- vLLM-Omni version or commit: use the commit you are deploying from + +#### Command + +Requires the `vllm-omni` package (or the `quay.io/atlas-ci/vllm-ascend` A2 / A3 container), +which provides the `vllm serve … --omni` entrypoint used below. + +Safety guardrails are **on by default** (NVIDIA Open Model License). They load +the **gated** `nvidia/Cosmos-1.0-Guardrail` model, so to keep them on you must: + +1. `pip install cosmos-guardrail` +2. Accept the license at <https://huggingface.co/nvidia/Cosmos-1.0-Guardrail> +3. Export a token with access: `export HF_TOKEN=hf_...` + +Then launch the recommended server: + +```bash +vllm serve nvidia/Cosmos3-Nano \ + --omni \ + --host 0.0.0.0 --port 8000 \ + --init-timeout 1800 +``` + +To run **without** guardrails (you are responsible for license compliance), +add `--no-guardrails` (no token/`cosmos-guardrail` needed). For tensor parallel +add `--tensor-parallel-size 8`. `--quantization fp8` and +`--enable-layerwise-offload` are not supported on NPU. +The pipeline auto-resolves from `model_index.json`; pass +`--model-class-name Cosmos3OmniDiffusersPipeline` to force it explicitly. + +#### Verification + +Best quality uses the JSON-upsampled prompts from `assets/` (download with +`hf download nvidia/Cosmos3-Nano assets/ --local-dir Cosmos3-Nano`). Minimal +self-contained examples: + +```bash +curl http://localhost:8000/v1/models + +# Text-to-image -> /v1/images/generations (1024x1024, 10 steps; base64 PNG) +curl -sS -X POST http://localhost:8000/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "nvidia/Cosmos3-Nano", + "prompt": "A photorealistic red sports car on a city street at golden hour, cinematic lighting.", + "negative_prompt": "blurry, distorted, low quality", + "size": "1024x1024", "n": 1, "response_format": "b64_json", + "num_inference_steps": 10, "guidance_scale": 7.0, "seed": 42 + }' | python -c "import sys,json,base64; open('cosmos3_t2i.png','wb').write(base64.b64decode(json.load(sys.stdin)['data'][0]['b64_json']))" + +# Text-to-video -> /v1/videos/sync (720p, 49 frames @ 24fps) +curl -sS -X POST http://localhost:8000/v1/videos/sync \ + -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Nano" \ + -F "prompt=A robot arm is cleaning a plate in the kitchen" \ + -F "negative_prompt=blurry, distorted, low quality, jittery, deformed" \ + -F "size=1280x720" -F "num_frames=49" -F "fps=24" \ + -F "num_inference_steps=20" -F "guidance_scale=6.0" \ + -F "max_sequence_length=4096" -F "flow_shift=10.0" \ + -F "seed=123" \ + -o cosmos3_t2v.mp4 + +# Image-to-video -> /v1/videos/sync with an uploaded reference image +curl -sS -X POST http://localhost:8000/v1/videos/sync \ + -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Nano" \ + -F "prompt=The scene comes to life with smooth, natural motion." \ + -F "size=1280x720" -F "num_frames=25" -F "fps=8" \ + -F "num_inference_steps=10" -F "guidance_scale=6.0" \ + -F "seed=42" \ + -F "input_reference=@reference.jpg;type=image/jpeg" \ + -o cosmos3_i2v.mp4 + +# Video-to-video -> /v1/videos/sync with an uploaded reference video +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=17" -F "fps=5" \ + -F "num_inference_steps=10" -F "guidance_scale=6.0" \ + -F "seed=42" \ + -F "input_reference=@reference.mp4;type=video/mp4" \ + -o cosmos3_v2v.mp4 +``` + +#### Notes + +- **Measured latency (1x Ascend 910B / 910C, bf16, guardrails off):** + - T2I 1024² — 10 steps → ~8 s + - T2V 1280×720 @ 20 steps — 49 frames → ~55 s + - I2V 1280×720 @ 10 steps — 25 frames → ~25 s + - V2V 480×320 @ 10 steps — 17 frames → ~12 s +- **Memory:** transformer ~17 GiB (bf16); peak ~46 GiB for 720p video on 1 NPU; + full repo (transformer + Wan VAE + Qwen3-VL vision encoder + audio tokenizer) + ~33 GB on disk. +- **Determinism:** identical seed reproduces identical output on the same + hardware; outputs are not bit-identical across different GPU/NPU 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/V2V + 1280×720, 35 steps, guidance 6.0, `flow_shift=10.0`. +- **Key flags / params:** `--no-guardrails` (optional, to disable guardrails), `--init-timeout 1800` + (for model loading), `--tensor-parallel-size 8` for multi-NPU, and + `--model-class-name Cosmos3OmniDiffusersPipeline` to force the pipeline class. +- **Known limitations:** + - Transfer V2V with `extra_params` (`edge`/`blur`/`depth`/`seg`/`wsm`) hits a + resolution-parsing bug; basic V2V without transfer hints works. + - FP8 online quantization and layerwise offload are not supported on NPU. + +### 1x Ascend 910B / 910C (Atlas A2 / A3) — Offline generation + +#### Environment + +- OS: Linux (aarch64) +- Python: 3.12+ +- Driver / runtime: CANN 8.5.1 + NNAL + Ascend 910B / 910C +- vLLM-Omni version or commit: use the commit you are deploying from + +#### Command + +The same offline task examples run on NPU; pass model-specific knobs via +`--extra-body`. Guardrails are on by default — pass `"guardrails": false` for a +quick local run (install `cosmos-guardrail` + accept the gated repo to enable them). + +```bash +# Text-to-image -> examples/offline_inference/text_to_image +python examples/offline_inference/text_to_image/text_to_image.py \ + --model nvidia/Cosmos3-Nano \ + --prompt "A photorealistic red sports car at golden hour, cinematic lighting." \ + --negative-prompt "blurry, distorted, low quality" \ + --height 1024 --width 1024 --num-inference-steps 50 --guidance-scale 7.0 \ + --extra-body '{"flow_shift": 3.0, "guardrails": false}' \ + --output cosmos3_t2i.png + +# Text-to-video -> examples/offline_inference/text_to_video +python examples/offline_inference/text_to_video/text_to_video.py \ + --model nvidia/Cosmos3-Nano \ + --prompt "A robot arm is cleaning a plate in the kitchen." \ + --negative-prompt "blurry, distorted, low quality, jittery, deformed" \ + --height 720 --width 1280 --num-frames 189 --fps 24 \ + --num-inference-steps 35 --guidance-scale 6.0 \ + --extra-body '{"flow_shift": 10.0, "max_sequence_length": 4096, "guardrails": false, + "use_resolution_template": false, "use_duration_template": false}' \ + --output cosmos3_t2v.mp4 + +# Image-to-video -> examples/offline_inference/image_to_video +# (Cosmos3 bundles example frames under assets/; any RGB image works too.) +python examples/offline_inference/image_to_video/image_to_video.py \ + --model nvidia/Cosmos3-Nano \ + --image /path/to/Cosmos3-Nano/assets/example_i2v_input.jpg \ + --prompt "The scene comes to life with smooth, natural motion." \ + --height 720 --width 1280 --num-frames 189 --fps 24 \ + --num-inference-steps 35 --guidance-scale 6.0 \ + --extra-body '{"flow_shift": 10.0, "max_sequence_length": 4096, "guardrails": false}' \ + --output cosmos3_i2v.mp4 +``` + +#### Verification + +```bash +python -c "from PIL import Image; im=Image.open('cosmos3_t2i.png'); print('image', im.size, im.mode)" +ffprobe -v error -show_entries stream=codec_type,nb_frames,width,height cosmos3_t2v.mp4 +``` + +#### Notes + +- Guardrails are on by default on NPU with `cosmos-guardrail` installed. Pass + `"guardrails": false` in `--extra-body` to disable them (there is no + `--no-guardrails` flag for the offline scripts). +- Video at the 189-frame default takes ~15 min/clip on 1 NPU; reduce + `--num-frames` for faster iteration. diff --git a/vllm_omni/diffusion/models/cosmos3/guardrails.py b/vllm_omni/diffusion/models/cosmos3/guardrails.py index e249d2826..3c6166e85 100644 --- a/vllm_omni/diffusion/models/cosmos3/guardrails.py +++ b/vllm_omni/diffusion/models/cosmos3/guardrails.py @@ -21,6 +21,7 @@ from vllm.logger import init_logger from vllm_omni.diffusion.models.progress_bar import _is_rank_zero from vllm_omni.errors import GuardrailViolationError +from vllm_omni.platforms import current_omni_platform if TYPE_CHECKING: from vllm_omni.diffusion.data import OmniDiffusionConfig @@ -28,6 +29,28 @@ if TYPE_CHECKING: logger = init_logger(__name__) +# --------------------------------------------------------------------------- +# NPU/XPU compatibility: cosmos_guardrail.cosmos_utils.load_model calls +# ``torch.load(path, weights_only=True)`` without ``map_location``. +# On NPU/XPU, ``torch.cuda.is_available()`` is False, so deserializing a +# CUDA checkpoint with ``weights_only=True`` raises an UnpicklingError. +# Work around by patching ``torch.load`` to default ``map_location="cpu"`` +# when CUDA is unavailable and no explicit map_location was given. +# --------------------------------------------------------------------------- +_original_torch_load = torch.load + + +def _patched_torch_load(*args, **kwargs): + if ( + "map_location" not in kwargs + and not torch.cuda.is_available() + and (current_omni_platform.is_npu() or current_omni_platform.is_xpu()) + ): + kwargs["map_location"] = "cpu" + return _original_torch_load(*args, **kwargs) + + +torch.load = _patched_torch_load # type: ignore[assignment] try: from cosmos_guardrail import CosmosSafetyChecker @@ -75,7 +98,7 @@ def _build_text_guardrail(checker: Any) -> TextGuardrailFn: def _build_video_guardrail(checker: Any, offload_to_cpu: bool) -> VideoGuardrailFn: video_models = _nn_models(checker.video_guardrail) - compute_device = "cuda" + compute_device = current_omni_platform.device_type def video_guardrail(frames: np.ndarray) -> np.ndarray: if offload_to_cpu: @@ -112,9 +135,9 @@ def _init_default_guardrails(offload_to_cpu: bool = False) -> None: checker = CosmosSafetyChecker() # Place text models on their resting device permanently. Video models - # idle on CPU when offload is on and move to GPU per-call (handled in + # idle on CPU when offload is on and move to compute device per-call (handled in # the video guardrail closure). - idle_device = "cpu" if offload_to_cpu else "cuda" + idle_device = "cpu" if offload_to_cpu else current_omni_platform.device_type for m in _nn_models(checker.text_guardrail): m.to(idle_device) for m in _nn_models(checker.video_guardrail): From 3589a676e22bc1cee8ccc4d0f62ddc5834aa2eaa Mon Sep 17 00:00:00 2001 From: wjsuijlenh <127192506+wjsuijlenh@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:22:40 +0200 Subject: [PATCH 081/101] [Bugfix] Accept kv_prefetch_jobs in ARDiffusionModelRunner.execute_model (#4941) Signed-off-by: Wijnand Suijlen <wijnand.suijlen@huawei.com> --- tests/ar_diffusion/test_engine_routing.py | 14 ++++++++++++++ tests/ar_diffusion/test_pipeline_bridge.py | 2 +- vllm_omni/experimental/ar_diffusion/runner.py | 6 +++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/ar_diffusion/test_engine_routing.py b/tests/ar_diffusion/test_engine_routing.py index fc59e54ac..fb55e01e9 100644 --- a/tests/ar_diffusion/test_engine_routing.py +++ b/tests/ar_diffusion/test_engine_routing.py @@ -134,3 +134,17 @@ def test_runner_rejects_multi_sequence_config(): fake = SimpleNamespace(od_config=SimpleNamespace(max_num_seqs=4)) with pytest.raises(ValueError, match="max_num_seqs=1"): ARDiffusionModelRunner._preallocate_kv_cache(fake) + + +def test_execute_model_accepts_kv_prefetch_jobs_kwarg(): + """Regression: the worker passes ``kv_prefetch_jobs`` to every runner + (added with the KV prefetch path, #4448); the AR-Diffusion override must + accept and forward it or DreamZero crashes on the warm-up dummy run.""" + import inspect + + from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner + from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner + + for cls in (DiffusionModelRunner, ARDiffusionModelRunner): + params = inspect.signature(cls.execute_model).parameters + assert "kv_prefetch_jobs" in params, cls diff --git a/tests/ar_diffusion/test_pipeline_bridge.py b/tests/ar_diffusion/test_pipeline_bridge.py index 83c710efa..28c3c959d 100644 --- a/tests/ar_diffusion/test_pipeline_bridge.py +++ b/tests/ar_diffusion/test_pipeline_bridge.py @@ -203,7 +203,7 @@ def test_failed_forward_tears_down_session(monkeypatch): runner.device = None runner._perf_e2e_times = [] - def boom(self, req): + def boom(self, req, kv_prefetch_jobs=None): raise RuntimeError("layer 17 exploded") monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom) diff --git a/vllm_omni/experimental/ar_diffusion/runner.py b/vllm_omni/experimental/ar_diffusion/runner.py index c8b8e93e1..862b3d9e8 100644 --- a/vllm_omni/experimental/ar_diffusion/runner.py +++ b/vllm_omni/experimental/ar_diffusion/runner.py @@ -234,10 +234,10 @@ class ARDiffusionModelRunner(DiffusionModelRunner): num_frame_per_block=num_frame_per_block, ) - def execute_model(self, req: OmniDiffusionRequest) -> DiffusionOutput: + def execute_model(self, req: OmniDiffusionRequest, kv_prefetch_jobs: dict | None = None) -> DiffusionOutput: # KV disabled -> base behavior, unchanged. if self.kv_cache is None: - return super().execute_model(req) + return super().execute_model(req, kv_prefetch_jobs=kv_prefetch_jobs) kv = self.kv_cache # DreamZero KV is session-scoped (the model state persists across a @@ -273,7 +273,7 @@ class ARDiffusionModelRunner(DiffusionModelRunner): # sync below makes the stop reflect completed GPU work, not just dispatch. _e2e_t0 = time.perf_counter() try: - out = super().execute_model(req) + out = super().execute_model(req, kv_prefetch_jobs=kv_prefetch_jobs) except Exception: # Transactional containment: a forward that died partway may have # written some layers' K/V into allocated-but-uncommitted blocks From 4b9d09cb8752631c9cb704e91028a800274f89d8 Mon Sep 17 00:00:00 2001 From: Alex Steiner <pst2154@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:24:26 -0700 Subject: [PATCH 082/101] [Bugfix][Quantization] Support packed parameters with HSDP (#5088) Signed-off-by: Alex Steiner <asteiner@nvidia.com> --- tests/diffusion/distributed/test_hsdp.py | 86 ++++++++++++++++++- .../model_loader/test_diffusers_loader.py | 53 ++++++++++++ vllm_omni/diffusion/distributed/hsdp.py | 48 ++++++++++- .../model_loader/diffusers_loader.py | 12 ++- 4 files changed, 187 insertions(+), 12 deletions(-) diff --git a/tests/diffusion/distributed/test_hsdp.py b/tests/diffusion/distributed/test_hsdp.py index e6d782c0a..91c6784fc 100644 --- a/tests/diffusion/distributed/test_hsdp.py +++ b/tests/diffusion/distributed/test_hsdp.py @@ -1,19 +1,97 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unit tests for HSDP (Hybrid Sharded Data Parallel) configuration and utilities. +"""Unit tests for HSDP (Hybrid Sharded Data Parallel) configuration and utilities.""" -These tests verify HSDP configuration logic without requiring a distributed environment. -""" +import gc +import os +import socket import pytest +import torch +import torch.distributed as dist import torch.nn as nn +from torch.distributed.tensor import DeviceMesh, DTensor from vllm_omni.diffusion.data import DiffusionParallelConfig -from vllm_omni.diffusion.distributed.hsdp import HSDPInferenceConfig +from vllm_omni.diffusion.distributed.hsdp import ( + HSDPInferenceConfig, + _unshardable_parameters, + shard_model, +) pytestmark = [pytest.mark.diffusion, pytest.mark.parallel, pytest.mark.cpu, pytest.mark.core_model] +class _PackedBlock(nn.Module): + def __init__(self): + super().__init__() + self.weight = nn.Parameter(torch.ones(2, 2)) + self.packed_weight = nn.Parameter(torch.ones(2, 2, dtype=torch.uint8), requires_grad=False) + self.input_global_scale = nn.Parameter(torch.tensor(1.0), requires_grad=False) + + +class _PackedModel(nn.Module): + def __init__(self): + super().__init__() + self.block = _PackedBlock() + self.root_weight = nn.Parameter(torch.ones(2)) + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@pytest.fixture(scope="module") +def cpu_process_group(): + if dist.is_initialized(): + yield + return + + master_port = _find_free_port() + os.environ.update( + { + "RANK": "0", + "LOCAL_RANK": "0", + "WORLD_SIZE": "1", + "MASTER_ADDR": "127.0.0.1", + "MASTER_PORT": str(master_port), + } + ) + dist.init_process_group("gloo", rank=0, world_size=1) + try: + yield + finally: + dist.destroy_process_group() + for key in ("MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"): + os.environ.pop(key, None) + gc.collect() + + +def test_hsdp_keeps_packed_and_scalar_parameters_local(cpu_process_group): + model = _PackedModel() + ignored_params = _unshardable_parameters(model) + expected_ignored = {model.block.packed_weight, model.block.input_global_scale} + assert ignored_params == expected_ignored + + packed_weight = model.block.packed_weight + input_global_scale = model.block.input_global_scale + shard_model( + model, + mesh=DeviceMesh("cpu", [0]), + hsdp_shard_conditions=[lambda name, _module: name == "block"], + ignored_params=ignored_params, + ) + + assert isinstance(model.block.weight, DTensor) + assert isinstance(model.root_weight, DTensor) + assert model.block.packed_weight is packed_weight + assert model.block.input_global_scale is input_global_scale + assert not isinstance(model.block.packed_weight, DTensor) + assert not isinstance(model.block.input_global_scale, DTensor) + + class TestHSDPInferenceConfig: """Tests for HSDPInferenceConfig dataclass.""" diff --git a/tests/diffusion/model_loader/test_diffusers_loader.py b/tests/diffusion/model_loader/test_diffusers_loader.py index f884f3667..f370d5b49 100644 --- a/tests/diffusion/model_loader/test_diffusers_loader.py +++ b/tests/diffusion/model_loader/test_diffusers_loader.py @@ -179,6 +179,59 @@ def test_load_model_custom_pipeline_sets_current_diffusion_config(monkeypatch): assert get_current_diffusion_config_or_none() is None +def test_hsdp_processes_quantized_weights_before_sharding(mocker): + import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod + from vllm_omni.diffusion.offloader.module_collector import PipelineModules + + od_config = SimpleNamespace( + dtype=torch.float32, + parallel_config=SimpleNamespace( + use_hsdp=True, + hsdp_replicate_size=1, + hsdp_shard_size=2, + ), + quantization_config=None, + ) + loader = DiffusersPipelineLoader(LoadConfig(), od_config) + loader.quant_config = object() + + model = nn.Module() + model.transformer = nn.Linear(2, 2, bias=False) + events: list[str] = [] + + loader._init_from_load_format = mocker.Mock(return_value=model) # type: ignore[method-assign] + loader.load_weights = mocker.Mock(side_effect=lambda _model: events.append("load")) # type: ignore[method-assign] + loader._process_weights_after_loading = mocker.Mock( # type: ignore[method-assign] + side_effect=lambda _model, _device: events.append("process") + ) + mocker.patch.object( + loader_mod.ModuleDiscovery, + "discover", + return_value=PipelineModules( + dits=[model.transformer], + dit_names=["transformer"], + vaes=[], + encoders=[], + encoder_names=[], + resident_modules=[], + resident_names=[], + ), + ) + mocker.patch( + "vllm_omni.diffusion.quantization.hsdp_fp8.prepare_fp8_layers_for_fsdp", + side_effect=lambda _model: events.append("prepare"), + ) + mocker.patch.object( + loader_mod, + "apply_hsdp_to_model", + side_effect=lambda *_args, **_kwargs: events.append("shard"), + ) + + loader._load_model_with_hsdp(torch.device("cpu")) + + assert events == ["load", "process", "prepare", "shard"] + + 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( diff --git a/vllm_omni/diffusion/distributed/hsdp.py b/vllm_omni/diffusion/distributed/hsdp.py index 3a056d6d4..cae4356f4 100644 --- a/vllm_omni/diffusion/distributed/hsdp.py +++ b/vllm_omni/diffusion/distributed/hsdp.py @@ -26,6 +26,15 @@ from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) +def _unshardable_parameters(model: nn.Module) -> set[nn.Parameter]: + """Return packed/integer or scalar parameters unsupported by FSDP2.""" + return { + param + for param in model.parameters() + if param.ndim == 0 or not (param.is_floating_point() or param.is_complex()) + } + + @dataclass class HSDPInferenceConfig: """Configuration for HSDP inference. @@ -202,6 +211,32 @@ def apply_hsdp_to_model( target_device, ) + # Serialized low-bit checkpoints may store packed weights in integer + # Parameters (for example, ModelOpt NVFP4 uses uint8) and global scales as + # scalar Parameters. FSDP2 cannot represent non-floating or zero-dimensional + # sharded parameters. Keep those tensors resident and replicated; eligible + # scales, biases, and other parameters remain HSDP-sharded. + unshardable_params = _unshardable_parameters(model) + if unshardable_params: + if target_device is None: + raise ValueError( + f"Model {type(model).__name__} has parameters that HSDP must ignore, " + "but apply_hsdp_to_model was called without target_device." + ) + for param in unshardable_params: + if param.device != target_device: + param.data = param.data.to(target_device) + ignored_params.update(unshardable_params) + logger.info( + "HSDP excluding %d unshardable parameter tensors from sharding " + "(non_floating=%d, scalar=%d, dtypes=%s, moved to %s)", + len(unshardable_params), + sum(not (param.is_floating_point() or param.is_complex()) for param in unshardable_params), + sum(param.ndim == 0 for param in unshardable_params), + sorted({str(param.dtype) for param in unshardable_params}), + target_device, + ) + # Apply HSDP sharding, this will automatically handle weight distribution shard_model( model, @@ -233,9 +268,9 @@ def shard_model( ignored_params (if provided) are excluded from the root fully_shard wrap, so they are not collected into the root flat-parameter, are not subject to MixedPrecisionPolicy, and retain their original dtype. - Per-submodule shard wraps do not receive ignored_params because the - ignored modules are expected to live at the root level, not inside any - block matched by hsdp_shard_conditions. + Each per-submodule wrap receives the subset of ignored_params that it owns. + This is required for packed integer parameters inside sharded transformer + blocks; the root wrap receives the full set for all remaining parameters. """ hsdp_kwargs: dict[str, Any] = { "reshard_after_forward": reshard_after_forward, @@ -246,7 +281,12 @@ def shard_model( num_sharded = 0 for name, module in reversed(list(model.named_modules())): if any(cond(name, module) for cond in hsdp_shard_conditions): - fully_shard(module, **hsdp_kwargs) + module_kwargs = dict(hsdp_kwargs) + if ignored_params: + module_ignored_params = ignored_params.intersection(module.parameters()) + if module_ignored_params: + module_kwargs["ignored_params"] = module_ignored_params + fully_shard(module, **module_kwargs) num_sharded += 1 if num_sharded == 0: diff --git a/vllm_omni/diffusion/model_loader/diffusers_loader.py b/vllm_omni/diffusion/model_loader/diffusers_loader.py index e1aa0fd1c..8ee172e6b 100644 --- a/vllm_omni/diffusion/model_loader/diffusers_loader.py +++ b/vllm_omni/diffusion/model_loader/diffusers_loader.py @@ -381,10 +381,9 @@ class DiffusersPipelineLoader: cast(DiffusersAdapterPipeline, model).load_weights() else: self.load_weights(model) - - # Process weights after loading for quantization (e.g., FP8 online quantization) - # This is needed for vLLM's quantization methods that need to transform weights - self._process_weights_after_loading(model, target_device) + # HSDP processes quantized weights before wrapping parameters as + # DTensors. The non-HSDP path can process them here as usual. + self._process_weights_after_loading(model, target_device) if offload_after_quant: model.to("cpu") @@ -629,6 +628,11 @@ class DiffusersPipelineLoader: model = self._init_from_load_format(load_format, target_device, custom_pipeline_name, is_hsdp=True) self.load_weights(model) + # Quantization methods must finish while parameters are ordinary local + # tensors. Some post-load transforms use operations (for example, + # torch.unique in ModelOpt NVFP4) that do not support DTensor inputs. + self._process_weights_after_loading(model, target_device) + # Discover pipeline components (DiT, encoders, VAEs) via # ModuleDiscovery, which consults SupportsComponentDiscovery # when available and falls back to well-known attribute names. From 1a4a042a8307033a5f2a8ebf34ec866737560994 Mon Sep 17 00:00:00 2001 From: bjf-frz <frz123db@gmail.com> Date: Tue, 14 Jul 2026 17:24:03 +0800 Subject: [PATCH 083/101] [REFACT]Refactor diffusion outputs to payload metadata (#4922) Signed-off-by: bjf-frz <frz123db@gmail.com> --- .../diffusion/frame_interpolation.md | 3 +- examples/offline_inference/lance/end2end.py | 13 +- .../offline_inference/lance/gradio_demo.py | 17 +- .../offline_inference/sensenova_u1/end2end.py | 13 +- .../models/cosmos3/test_cosmos3_pipeline.py | 82 ++++-- .../qwen_image/test_qwen_image_postprocess.py | 50 ++++ .../test_diffusion_output_formatter.py | 176 ++++++++++--- .../test_diffusion_output_metadata.py | 97 +++++++ .../diffusion/test_diffusion_plugin_hooks.py | 19 +- .../test_diffusion_streaming_output.py | 51 +++- tests/e2e/accuracy/test_hunyuan_image3.py | 5 +- .../qwen_image_pipeline_with_logprob.py | 22 +- .../rlhf_test/test_verl_omni_e2e.py | 19 +- .../openai_api/test_video_server.py | 97 +++++-- vllm_omni/diffusion/data.py | 23 +- vllm_omni/diffusion/diffusion_engine.py | 48 +--- .../models/audiox/pipeline_audiox.py | 16 +- .../diffusion/models/bagel/pipeline_bagel.py | 38 +-- .../diffusion/models/cosmos3/__init__.py | 2 - vllm_omni/diffusion/models/cosmos3/action.py | 4 +- .../models/cosmos3/pipeline_cosmos3.py | 171 ++++++++---- .../hunyuan_image3/pipeline_hunyuan_image3.py | 21 +- .../internvla_a1/pipeline_internvla_a1.py | 10 +- .../diffusion/models/lance/pipeline_lance.py | 40 +-- .../models/qwen_image/pipeline_qwen_image.py | 13 +- .../sensenova_u1/pipeline_sensenova_u1.py | 17 +- .../modules/preprocess/pipeline.py | 8 +- .../pipeline_soulx_singer_base.py | 24 +- .../soulx_singer/pipeline_soulx_singer_svc.py | 5 +- .../soulx_singer/pipeline_soulx_singer_svs.py | 5 +- .../models/soulx_singer/preprocess/payload.py | 2 +- .../models/wan2_2/pipeline_wan2_2.py | 8 +- .../models/wan2_2/pipeline_wan2_2_i2v.py | 8 +- vllm_omni/diffusion/output_formatter.py | 245 +++++++++++++----- vllm_omni/diffusion/output_metadata.py | 10 + vllm_omni/diffusion/registry.py | 34 +-- vllm_omni/engine/output_modality.py | 3 - vllm_omni/engine/stage_client.py | 2 +- vllm_omni/engine/stage_runtime.py | 2 +- vllm_omni/entrypoints/openai/serving_chat.py | 40 ++- vllm_omni/entrypoints/openai/serving_video.py | 148 ++++++----- vllm_omni/model_extras/audiox.py | 2 +- vllm_omni/{outputs.py => outputs/__init__.py} | 0 vllm_omni/outputs/output_metadata.py | 238 +++++++++++++++++ 44 files changed, 1373 insertions(+), 478 deletions(-) create mode 100644 tests/diffusion/models/qwen_image/test_qwen_image_postprocess.py create mode 100644 tests/diffusion/test_diffusion_output_metadata.py create mode 100644 vllm_omni/diffusion/output_metadata.py rename vllm_omni/{outputs.py => outputs/__init__.py} (100%) create mode 100644 vllm_omni/outputs/output_metadata.py diff --git a/docs/user_guide/diffusion/frame_interpolation.md b/docs/user_guide/diffusion/frame_interpolation.md index ab03363cf..817ae67f7 100644 --- a/docs/user_guide/diffusion/frame_interpolation.md +++ b/docs/user_guide/diffusion/frame_interpolation.md @@ -47,7 +47,8 @@ For supported Wan2.2 pipelines, the execution order is: 1. Diffusion worker finishes denoising and decodes the raw video tensor. 2. Worker-side model-specific post-processing runs. 3. If frame interpolation is enabled, RIFE interpolates the decoded video - tensor on the worker side and records a FPS multiplier in `custom_output`. + tensor on the worker side and records a FPS multiplier in + `metadata.video.video_fps_multiplier`. 4. The API server receives the already-interpolated video and only performs MP4 export. diff --git a/examples/offline_inference/lance/end2end.py b/examples/offline_inference/lance/end2end.py index 504dceec6..d26910161 100644 --- a/examples/offline_inference/lance/end2end.py +++ b/examples/offline_inference/lance/end2end.py @@ -293,16 +293,19 @@ def main(): if args.modality in ("img2text", "video2text"): for i, req_output in enumerate(outputs): - text = getattr(req_output, "output", None) or getattr(req_output, "text", None) - if text is None and hasattr(req_output, "custom_output"): - text = (req_output.custom_output or {}).get("text_output") + multimodal_output = getattr(req_output, "multimodal_output", {}) or {} + metadata = multimodal_output.get("metadata", {}) if isinstance(multimodal_output, dict) else {} + text_metadata = metadata.get("text", {}) if isinstance(metadata, dict) else {} + text = (multimodal_output.get("text") if isinstance(multimodal_output, dict) else None) or ( + text_metadata.get("text_output") if isinstance(text_metadata, dict) else None + ) + text = text or getattr(req_output, "output", None) or getattr(req_output, "text", None) print(f"[Output {i}] {text}") return if args.modality in ("text2video", "video2video", "image2video"): for i, req_output in enumerate(outputs): - custom = getattr(req_output, "custom_output", None) or {} - frames = custom.get("video_frames") + frames = getattr(req_output, "images", None) if not frames: print(f"[Output {i}] no video frames returned") continue diff --git a/examples/offline_inference/lance/gradio_demo.py b/examples/offline_inference/lance/gradio_demo.py index 7dfeb46c9..756b006cb 100644 --- a/examples/offline_inference/lance/gradio_demo.py +++ b/examples/offline_inference/lance/gradio_demo.py @@ -835,12 +835,15 @@ def run_task( print(f"[unified] task={task} {width}x{height} frames={num_frames} cfg={cfg} steps={steps} seed={seed}", flush=True) outputs = list(omni.generate(prompts=formatted, sampling_params_list=params)) - custom = getattr(outputs[0], "custom_output", None) or {} - # Lance/Bagel return text at outputs[0].output (string) or - # custom_output["text_output"]. + multimodal_output = getattr(outputs[0], "multimodal_output", {}) or {} + metadata = multimodal_output.get("metadata", {}) if isinstance(multimodal_output, dict) else {} + text_metadata = metadata.get("text", {}) if isinstance(metadata, dict) else {} text_out = None if task in {"x2t_image", "x2t_video"}: - text_out = custom.get("text_output") or getattr(outputs[0], "output", None) + text_out = (multimodal_output.get("text") if isinstance(multimodal_output, dict) else None) or ( + text_metadata.get("text_output") if isinstance(text_metadata, dict) else None + ) + text_out = text_out or getattr(outputs[0], "output", None) if not isinstance(text_out, str): text_out = None @@ -856,13 +859,11 @@ def run_task( gr.update(value=str(text_out or "(empty)"), visible=True), ) if task in {"t2v", "i2v", "video_edit"}: - frames = custom.get("video_frames") + frames = getattr(outputs[0], "images", None) if frames is None: raise gr.Error("No video frames in output.") return (gr.update(visible=False), gr.update(value=_save_video(frames), visible=True), gr.update(visible=False)) - # image tasks — orchestrator strips ``outputs[0].output`` across the - # IPC boundary but preserves ``custom_output``, so prefer the latter. - img_out = custom.get("image") or getattr(outputs[0], "output", None) + img_out = (getattr(outputs[0], "images", None) or [None])[0] or getattr(outputs[0], "output", None) if img_out is None: raise gr.Error("No image in output.") return (gr.update(value=_save_image(img_out), visible=True), gr.update(visible=False), gr.update(visible=False)) diff --git a/examples/offline_inference/sensenova_u1/end2end.py b/examples/offline_inference/sensenova_u1/end2end.py index 7c727449e..9547c40b9 100644 --- a/examples/offline_inference/sensenova_u1/end2end.py +++ b/examples/offline_inference/sensenova_u1/end2end.py @@ -278,12 +278,17 @@ def main(): ) for req_output in outputs: - custom = getattr(req_output, "_custom_output", {}) or {} - if args.print_think and custom.get("think_text"): - print(f"[Think]\n{custom['think_text']}\n") + multimodal_output = getattr(req_output, "multimodal_output", {}) or {} + metadata = multimodal_output.get("metadata", {}) if isinstance(multimodal_output, dict) else {} + text_metadata = metadata.get("text", {}) if isinstance(metadata, dict) else {} + think_text = text_metadata.get("think_text") if isinstance(text_metadata, dict) else None + if args.print_think and think_text: + print(f"[Think]\n{think_text}\n") if is_text_output: - text = custom.get("text_output", "") + text = multimodal_output.get("text", "") if isinstance(multimodal_output, dict) else "" + if not text: + text = text_metadata.get("text_output", "") if isinstance(text_metadata, dict) else "" if not text: text = getattr(req_output, "text", "") or "" print(f"[Response]\n{text}") diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index f0159f82e..ba482e793 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -294,7 +294,6 @@ def test_pipeline_registered_and_exported() -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin from vllm_omni.diffusion.registry import ( - _DIFFUSION_ACTION_POST_PROCESS_FUNCS, _DIFFUSION_IR_OP_PRIORITY_FUNCS, _DIFFUSION_MODELS, _DIFFUSION_POST_PROCESS_FUNCS, @@ -311,9 +310,6 @@ def test_pipeline_registered_and_exported() -> None: ) assert _DIFFUSION_PRE_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_pre_process_func" assert _DIFFUSION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_post_process_func" - assert ( - _DIFFUSION_ACTION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_action_post_process_func" - ) assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_ir_op_priority_func" assert "Cosmos3OmniDiffusersPipeline" in CUSTOM_DIT_ENABLERS assert "Cosmos3OmniDiffusersPipeline" in cosmos3.__all__ @@ -718,11 +714,11 @@ def test_postprocess_handles_image_video_audio_and_validation() -> None: def test_action_postprocess_handles_robolab_policy_outputs() -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( RoboLabPolicyInputs, - get_cosmos3_action_post_process_func, + get_cosmos3_post_process_func, make_robolab_action_postprocess_inputs, ) - func = get_cosmos3_action_post_process_func(SimpleNamespace()) + func = get_cosmos3_post_process_func(SimpleNamespace()) inputs = RoboLabPolicyInputs( prompt="Pick the cube.", video_tensor=torch.zeros(1, 3, 3, 16, 16), @@ -746,13 +742,41 @@ def test_action_postprocess_handles_robolab_policy_outputs() -> None: ) action = torch.tensor([[[0.0, 0.25], [1.0, 0.75]]]) - custom_output = {"robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs)} - processed = func(action, custom_output=custom_output) + processed = func( + { + "payload": { + "actions": action, + }, + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + "common": { + "action_only_output": True, + }, + "internal": { + "robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs), + }, + }, + } + ) - assert processed.shape == (1, 2) - assert processed.dtype == torch.zeros((), dtype=torch.float32).numpy().dtype - torch.testing.assert_close(torch.from_numpy(processed), torch.tensor([[1.0, 0.25]])) - assert "robolab_action_postprocess" not in custom_output + processed_action = processed["payload"]["actions"] + assert processed_action.shape == (1, 2) + assert processed_action.dtype == torch.zeros((), dtype=torch.float32).numpy().dtype + torch.testing.assert_close(torch.from_numpy(processed_action), torch.tensor([[1.0, 0.25]])) + assert processed["metadata"] == { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + "common": { + "action_only_output": True, + }, + } def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1208,8 +1232,8 @@ def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint assert captured["flow_shifts"] == [10.0] # Transfer uses the V2V flow-sigma schedule (karras off) so flow_shift applies. assert captured["scheduler_use_karras_sigmas"] == [False] - assert output.custom_output["fps"] == expected_fps - assert output.output["video"].device.type == "meta" + assert output.output["metadata"]["video"]["fps"] == expected_fps + assert output.output["payload"]["video"].device.type == "meta" def test_forward_transfer_runs_multichunk_overlap_path( @@ -1278,12 +1302,12 @@ def test_forward_transfer_runs_multichunk_overlap_path( assert captured["conditional_frames"] == [2, 1] assert len(captured["decode_calls"]) == 2 - assert output.output["video"].shape == (1, 3, 8, 16, 16) + assert output.output["payload"]["video"].shape == (1, 3, 8, 16, 16) torch.testing.assert_close( - output.output["video"][0, 0, :, 0, 0], + output.output["payload"]["video"][0, 0, :, 0, 0], torch.tensor([-0.6, -0.5, -0.4, -0.3, -0.2, 0.2, 0.3, 0.4]), ) - assert output.custom_output["transfer_controls"]["edge"].shape == (1, 3, 8, 16, 16) + assert output.output["metadata"]["transfer"]["controls"]["edge"].shape == (1, 3, 8, 16, 16) torch.testing.assert_close(captured["targets"][0][:, :, 0], torch.full((1, 3, 16, 16), -1.0)) torch.testing.assert_close(captured["targets"][0][:, :, 1], torch.full((1, 3, 16, 16), 1.0)) torch.testing.assert_close(captured["targets"][0][:, :, 2:], torch.full((1, 3, 3, 16, 16), 1.0)) @@ -1507,8 +1531,13 @@ class TestForwardRouting: ) ) assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7] - assert output.custom_output["action"].shape == (1, 2, 2) - assert "action_only_output" not in output.custom_output + assert output.output["payload"]["actions"].shape == (1, 2, 2) + assert output.output["metadata"]["actions"] == { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + } + assert "common" not in output.output["metadata"] def test_forward_dispatches_robolab_policy_flow( self, @@ -1592,12 +1621,15 @@ class TestForwardRouting: assert captured["prepare_action_video"]["kwargs"] == {"image_size": None} assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7] assert captured["diffuse_calls"][-1]["timesteps"].tolist() == [4, 3, 2, 1] - assert output.output == {} - assert output.custom_output["action_only_output"] is True - assert output.custom_output["action"].shape == (1, 2, 2) - assert "actions" not in output.custom_output - assert "robolab_action_postprocess" in output.custom_output - assert "robolab_policy_inputs" not in output.custom_output + assert output.output["payload"]["actions"].shape == (1, 2, 2) + assert output.output["metadata"]["actions"] == { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + } + assert output.output["metadata"]["common"]["action_only_output"] is True + assert "robolab_action_postprocess" in output.output["metadata"]["internal"] + assert "robolab_policy_inputs" not in output.output["metadata"] @pytest.mark.parametrize( ("prompt", "sampling_params", "message"), diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_postprocess.py b/tests/diffusion/models/qwen_image/test_qwen_image_postprocess.py new file mode 100644 index 000000000..62d17f705 --- /dev/null +++ b/tests/diffusion/models/qwen_image/test_qwen_image_postprocess.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import json +from types import SimpleNamespace + +import pytest +import torch + +import vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image as qwen_image + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +def test_qwen_image_postprocess_preserves_output_envelope( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + model_path = tmp_path / "model" + vae_path = model_path / "vae" + vae_path.mkdir(parents=True) + (vae_path / "config.json").write_text(json.dumps({}), encoding="utf-8") + + processed = object() + captured: dict[str, object] = {} + + class FakeImageProcessor: + def __init__(self, *, vae_scale_factor: int) -> None: + captured["vae_scale_factor"] = vae_scale_factor + + def postprocess(self, images: torch.Tensor) -> object: + captured["images"] = images + return processed + + monkeypatch.setattr(qwen_image, "VaeImageProcessor", FakeImageProcessor) + postprocess = qwen_image.get_qwen_image_post_process_func(SimpleNamespace(model=str(model_path))) + + image = torch.zeros(1, 3, 4, 4) + output = postprocess( + { + "payload": {"image": image}, + "metadata": {"prompt_embeddings": {"present": True}}, + } + ) + + assert captured["vae_scale_factor"] == 16 + assert captured["images"] is image + assert output == { + "payload": {"image": processed}, + "metadata": {"prompt_embeddings": {"present": True}}, + } diff --git a/tests/diffusion/test_diffusion_output_formatter.py b/tests/diffusion/test_diffusion_output_formatter.py index 185d51b54..22b305eab 100644 --- a/tests/diffusion/test_diffusion_output_formatter.py +++ b/tests/diffusion/test_diffusion_output_formatter.py @@ -51,21 +51,23 @@ def _timings() -> DiffusionStepTimings: ) -def test_formatter_preserves_single_video_audio_actions_and_custom_output( +def test_formatter_preserves_single_video_audio_actions_and_metadata( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(output_formatter, "supports_audio_output", lambda _: False) - custom_output = {"from_output": "kept"} postprocess_output = normalize_diffusion_postprocess_output( { - "video": ["frame-0"], - "audio": "audio-0", - "actions": "action-0", - "custom_output": {"from_postprocess": "merged"}, - "audio_sample_rate": 48000, - "fps": 24.0, - }, - custom_output, + "payload": { + "video": ["frame-0"], + "audio": "audio-0", + "actions": "action-0", + }, + "metadata": { + "audio": {"sample_rate": 48000}, + "video": {"fps": 24.0}, + "actions": {"action_mode": "policy"}, + }, + } ) results = format_diffusion_outputs( @@ -73,7 +75,6 @@ def test_formatter_preserves_single_video_audio_actions_and_custom_output( od_config=_config(), diffusion_output=DiffusionOutput( output=None, - custom_output=custom_output, stage_durations={"execute": 1.25}, peak_memory_mb=321.0, ), @@ -87,12 +88,12 @@ def test_formatter_preserves_single_video_audio_actions_and_custom_output( assert result.images == ["frame-0"] assert result.prompt == "prompt-0" assert result.final_output_type == "image" - assert result.custom_output == { - "from_output": "kept", - "from_postprocess": "merged", - } - assert custom_output == {"from_output": "kept"} assert result.multimodal_output == { + "metadata": { + "audio": {"sample_rate": 48000}, + "video": {"fps": 24.0}, + "actions": {"action_mode": "policy"}, + }, "audio": "audio-0", "audio_sample_rate": 48000, "fps": 24.0, @@ -110,18 +111,123 @@ def test_formatter_preserves_single_video_audio_actions_and_custom_output( } -def test_formatter_preserves_text_custom_output(monkeypatch: pytest.MonkeyPatch) -> None: +def test_formatter_normalizes_payload_metadata_envelope( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setattr(output_formatter, "supports_audio_output", lambda _: False) postprocess_output = normalize_diffusion_postprocess_output( - "caption", - {"text_output": "caption"}, + { + "payload": { + "video": ["frame-0"], + "actions": "action-0", + }, + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + "common": { + "action_only_output": True, + }, + "internal": { + "robolab_action_postprocess": object(), + }, + }, + } + ) + + assert postprocess_output.outputs == { + "video": ["frame-0"], + "actions": "action-0", + } + assert postprocess_output.primary_key == "video" + assert postprocess_output.metadata == { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + "common": { + "action_only_output": True, + }, + } + assert "internal" not in postprocess_output.metadata + + [result] = format_diffusion_outputs( + request=_request("prompt-0"), + od_config=_config(), + diffusion_output=DiffusionOutput(output=None), + output_data={"raw": "output"}, + postprocess_output=postprocess_output, + timings=_timings(), + ) + + assert result.multimodal_output == { + "metadata": postprocess_output.metadata, + "actions": "action-0", + } + + +def test_formatter_maps_trajectory_payload_to_request_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(output_formatter, "supports_audio_output", lambda _: False) + latents = torch.zeros(2, 1, 4) + timesteps = torch.tensor([1.0, 0.5]) + log_probs = torch.tensor([-0.1, -0.2]) + postprocess_output = normalize_diffusion_postprocess_output( + { + "payload": { + "image": "image-0", + "trajectory": { + "latents": latents, + "timesteps": timesteps, + "log_probs": log_probs, + }, + }, + "metadata": {"trajectory": {"type": "denoising"}}, + } + ) + + [result] = format_diffusion_outputs( + request=_request("prompt-0"), + od_config=_config(), + diffusion_output=DiffusionOutput(output=None), + output_data={"raw": "output"}, + postprocess_output=postprocess_output, + timings=_timings(), + ) + + assert result.images == ["image-0"] + assert result.latents is latents + assert result.trajectory_latents is latents + assert result.trajectory_timesteps is timesteps + assert result.trajectory_log_probs is log_probs + assert result.multimodal_output == { + "metadata": {"trajectory": {"type": "denoising"}}, + "trajectory": { + "latents": latents, + "timesteps": timesteps, + "log_probs": log_probs, + }, + } + + +def test_formatter_preserves_text_envelope_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(output_formatter, "supports_audio_output", lambda _: False) + postprocess_output = normalize_diffusion_postprocess_output( + { + "payload": {"text": "caption"}, + "metadata": {"text": {"text_output": "caption"}}, + } ) [result] = format_diffusion_outputs( request=_request("describe this"), od_config=_config(), - diffusion_output=DiffusionOutput(output="caption"), - output_data="caption", + diffusion_output=DiffusionOutput(output=None), + output_data={"payload": {"text": "caption"}}, postprocess_output=postprocess_output, timings=_timings(), ) @@ -129,7 +235,10 @@ def test_formatter_preserves_text_custom_output(monkeypatch: pytest.MonkeyPatch) assert result.images == [] assert result.prompt == "describe this" assert result.final_output_type == "text" - assert result.custom_output == {"text_output": "caption"} + assert result.multimodal_output == { + "metadata": {"text": {"text_output": "caption"}}, + "text": "caption", + } def test_formatter_preserves_audio_output_with_model_sample_rate_fallback( @@ -144,7 +253,7 @@ def test_formatter_preserves_audio_output_with_model_sample_rate_fallback( "_try_load_model_cls", lambda _: AudioModel, ) - postprocess_output = normalize_diffusion_postprocess_output(["waveform"], {}) + postprocess_output = normalize_diffusion_postprocess_output(["waveform"]) [result] = format_diffusion_outputs( request=_request("speak"), @@ -175,8 +284,7 @@ def test_formatter_preserves_audio_model_video_audio_and_actions( "actions": "action-0", "audio_sample_rate": 16000, "fps": 30.0, - }, - {}, + } ) [result] = format_diffusion_outputs( @@ -192,6 +300,10 @@ def test_formatter_preserves_audio_model_video_audio_and_actions( assert result.prompt == "watch and listen" assert result.final_output_type == "image" assert result.multimodal_output == { + "metadata": { + "audio": {"sample_rate": 16000}, + "video": {"fps": 30.0}, + }, "audio": "audio-0", "audio_sample_rate": 16000, "fps": 30.0, @@ -207,8 +319,7 @@ def test_formatter_preserves_audio_only_postprocess_dict( { "audio": "waveform", "audio_sample_rate": 24000, - }, - {}, + } ) [result] = format_diffusion_outputs( @@ -224,6 +335,9 @@ def test_formatter_preserves_audio_only_postprocess_dict( assert result.prompt == "speak" assert result.final_output_type == "audio" assert result.multimodal_output == { + "metadata": { + "audio": {"sample_rate": 24000}, + }, "audio": "waveform", "audio_sample_rate": 24000, } @@ -238,7 +352,7 @@ def test_formatter_preserves_single_prompt_multiple_audio_outputs( "_try_load_model_cls", lambda _: None, ) - postprocess_output = normalize_diffusion_postprocess_output(["waveform-0", "waveform-1"], {}) + postprocess_output = normalize_diffusion_postprocess_output(["waveform-0", "waveform-1"]) [result] = format_diffusion_outputs( request=_request("speak", num_outputs_per_prompt=2), @@ -264,10 +378,8 @@ def test_formatter_preserves_single_prompt_audio_and_action_payloads( "video": ["frame-0", "frame-1"], "audio": ["audio-0", "audio-1"], "actions": torch.tensor([[1.0, 2.0], [3.0, 4.0]]), - "custom_output": {"shared": True}, "fps": 12.5, - }, - {}, + } ) results = format_diffusion_outputs( @@ -282,7 +394,7 @@ def test_formatter_preserves_single_prompt_audio_and_action_payloads( assert len(results) == 1 assert results[0].images == ["frame-0", "frame-1"] assert results[0].prompt == "prompt-0" - assert results[0].custom_output == {"shared": True} + assert results[0].multimodal_output["metadata"] == {"video": {"fps": 12.5}} assert results[0].multimodal_output["audio"] == ["audio-0", "audio-1"] assert results[0].multimodal_output["fps"] == 12.5 torch.testing.assert_close( diff --git a/tests/diffusion/test_diffusion_output_metadata.py b/tests/diffusion/test_diffusion_output_metadata.py new file mode 100644 index 000000000..ee3559f47 --- /dev/null +++ b/tests/diffusion/test_diffusion_output_metadata.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import pytest +import torch + +from vllm_omni.outputs.output_metadata import ( + strip_internal_metadata, + validate_diffusion_metadata, + validate_public_diffusion_metadata, +) + +pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu] + + +def test_validate_accepts_canonical_metadata_fields() -> None: + validate_diffusion_metadata( + { + "video": { + "fps": 24.0, + "video_fps_multiplier": 2, + "shape": [1, 3, 16, 64, 64], + }, + "audio": { + "sample_rate": 48000, + "audiox_task": "tts", + }, + "actions": { + "raw_action_dim": 7, + "action_mode": "policy", + "domain_id": 3, + }, + "transfer": { + "controls": {"edge": torch.zeros(1, 3, 4, 8, 8)}, + "hints": ["edge"], + }, + "text": { + "text_output": "caption", + "think_text": "reasoning", + }, + "common": { + "action_only_output": True, + }, + } + ) + + +@pytest.mark.parametrize( + ("metadata", "error_type"), + [ + ({"video": {"fps": 0}}, ValueError), + ({"video": {"fps": "24"}}, TypeError), + ({"video": {"video_fps_multiplier": 0}}, ValueError), + ({"video": {"shape": [1, 0, 64]}}, ValueError), + ({"audio": {"sample_rate": 0}}, ValueError), + ({"audio": {"audiox_task": ""}}, ValueError), + ({"actions": {"raw_action_dim": 0}}, ValueError), + ({"actions": {"action_mode": ""}}, ValueError), + ({"actions": {"domain_id": "7"}}, TypeError), + ({"transfer": {"controls": {"edge": "not-a-tensor"}}}, TypeError), + ({"transfer": {"hints": ["edge", 1]}}, TypeError), + ({"text": {"text_output": 1}}, TypeError), + ({"text": {"think_text": 1}}, TypeError), + ({"common": {"action_only_output": "true"}}, TypeError), + ], +) +def test_validate_rejects_invalid_canonical_metadata(metadata: dict[str, object], error_type: type[Exception]) -> None: + with pytest.raises(error_type): + validate_diffusion_metadata(metadata) + + +def test_validate_allows_unknown_model_metadata() -> None: + validate_diffusion_metadata( + { + "model_specific": {"opaque": object()}, + "video": {"model_key": object()}, + } + ) + + +def test_public_metadata_rejects_internal_group() -> None: + with pytest.raises(ValueError, match="internal"): + validate_public_diffusion_metadata({"internal": {"secret": object()}}) + + +def test_strip_internal_metadata_removes_private_group() -> None: + metadata = { + "actions": {"action_mode": "policy"}, + "internal": {"robolab_action_postprocess": object()}, + } + + public_metadata = strip_internal_metadata(metadata) + + assert public_metadata == {"actions": {"action_mode": "policy"}} + assert "internal" not in public_metadata diff --git a/tests/diffusion/test_diffusion_plugin_hooks.py b/tests/diffusion/test_diffusion_plugin_hooks.py index 6804d550d..a437a572a 100644 --- a/tests/diffusion/test_diffusion_plugin_hooks.py +++ b/tests/diffusion/test_diffusion_plugin_hooks.py @@ -16,7 +16,6 @@ from unittest.mock import Mock, patch import pytest from vllm_omni.diffusion.registry import ( - _DIFFUSION_ACTION_POST_PROCESS_FUNCS, _DIFFUSION_IR_OP_PRIORITY_FUNCS, _DIFFUSION_MODELS, _DIFFUSION_POST_PROCESS_FUNCS, @@ -62,7 +61,6 @@ class TestRegisterDiffusionModel: original_models = _DIFFUSION_MODELS.copy() original_pre = _DIFFUSION_PRE_PROCESS_FUNCS.copy() original_post = _DIFFUSION_POST_PROCESS_FUNCS.copy() - original_action_post = _DIFFUSION_ACTION_POST_PROCESS_FUNCS.copy() original_ir_op_priority = _DIFFUSION_IR_OP_PRIORITY_FUNCS.copy() yield _DIFFUSION_MODELS.clear() @@ -71,8 +69,6 @@ class TestRegisterDiffusionModel: _DIFFUSION_PRE_PROCESS_FUNCS.update(original_pre) _DIFFUSION_POST_PROCESS_FUNCS.clear() _DIFFUSION_POST_PROCESS_FUNCS.update(original_post) - _DIFFUSION_ACTION_POST_PROCESS_FUNCS.clear() - _DIFFUSION_ACTION_POST_PROCESS_FUNCS.update(original_action_post) _DIFFUSION_IR_OP_PRIORITY_FUNCS.clear() _DIFFUSION_IR_OP_PRIORITY_FUNCS.update(original_ir_op_priority) @@ -84,7 +80,6 @@ class TestRegisterDiffusionModel: class_name="TestPipeline", pre_process_func_name="test_pre_process", post_process_func_name="test_post_process", - action_post_process_func_name="test_action_post_process", ir_op_priority_func_name="test_ir_op_priority", ) assert "TestPipeline" in _DIFFUSION_MODELS @@ -95,9 +90,21 @@ class TestRegisterDiffusionModel: ) assert _DIFFUSION_PRE_PROCESS_FUNCS["TestPipeline"] == "test_pre_process" assert _DIFFUSION_POST_PROCESS_FUNCS["TestPipeline"] == "test_post_process" - assert _DIFFUSION_ACTION_POST_PROCESS_FUNCS["TestPipeline"] == "test_action_post_process" assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["TestPipeline"] == "test_ir_op_priority" + def test_register_model_accepts_deprecated_action_postprocess_keyword(self): + """Deprecated action hook keyword is accepted but not registered.""" + register_diffusion_model( + model_arch="LegacyActionPipeline", + module_name="test_plugin.diffusion.pipeline", + class_name="LegacyActionPipeline", + post_process_func_name="test_post_process", + action_post_process_func_name="test_action_post_process", + ) + + assert "LegacyActionPipeline" in _DIFFUSION_MODELS + assert _DIFFUSION_POST_PROCESS_FUNCS["LegacyActionPipeline"] == "test_post_process" + class TestWorkerUsesHook: """Test that DiffusionWorker resolves model runner via platform hook.""" diff --git a/tests/diffusion/test_diffusion_streaming_output.py b/tests/diffusion/test_diffusion_streaming_output.py index 50ed772c1..ae5f9eee8 100644 --- a/tests/diffusion/test_diffusion_streaming_output.py +++ b/tests/diffusion/test_diffusion_streaming_output.py @@ -85,19 +85,20 @@ class _PipelineBackedEngine: outputs = self.pipeline.step_outputs(request) except Exception as exc: outputs = [ - DiffusionOutput(custom_output={"chunk": 0}, finished=False), + _streaming_diffusion_output(chunk=0, finished=False), DiffusionOutput(error=str(exc), finished=True), ] for output in outputs: if output.error is not None: yield [OmniRequestOutput.from_error(request_id=request.request_id, error_message=output.error)] continue - custom_output = output.custom_output or {} + payload, metadata = _streaming_payload_and_metadata(output) + stream_metadata = metadata.get("stream", {}) yield [ OmniRequestOutput.from_diffusion( request_id=request.request_id, - images=list(custom_output.get("images") or []), - custom_output=custom_output, + images=list(payload.get("image") or []), + multimodal_output={"metadata": {"stream": stream_metadata}}, finished=output.finished, ) ] @@ -106,6 +107,30 @@ class _PipelineBackedEngine: del request_id +def _streaming_diffusion_output( + *, + chunk: int, + finished: bool, + images: list | None = None, +) -> DiffusionOutput: + payload = {"image": images or []} + metadata = {"stream": {"chunk": chunk}} + return DiffusionOutput( + output={ + "payload": payload, + "metadata": metadata, + }, + finished=finished, + ) + + +def _streaming_payload_and_metadata(output: DiffusionOutput) -> tuple[dict, dict]: + envelope = output.output if isinstance(output.output, dict) else {} + payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {} + metadata = envelope.get("metadata") if isinstance(envelope.get("metadata"), dict) else {} + return payload, metadata + + class TestPipelineStreamingOutputToStageDiffusionClient: """Streaming pipeline output over ZMQ into ``StageDiffusionClient``.""" @@ -114,8 +139,8 @@ class TestPipelineStreamingOutputToStageDiffusionClient: """Mock pipeline chunks over ZMQ reach StageDiffusionClient with correct finished flags.""" pipeline = _StepStreamingPipeline( [ - DiffusionOutput(custom_output={"chunk": 0}, finished=False), - DiffusionOutput(custom_output={"chunk": 1}, finished=True), + _streaming_diffusion_output(chunk=0, finished=False), + _streaming_diffusion_output(chunk=1, finished=True), ] ) @@ -123,7 +148,7 @@ class TestPipelineStreamingOutputToStageDiffusionClient: assert [output.request_id for output in outputs] == ["req-stream", "req-stream"] assert [output.finished for output in outputs] == [False, True] - assert [output.custom_output["chunk"] for output in outputs] == [0, 1] + assert [output.multimodal_output["metadata"]["stream"]["chunk"] for output in outputs] == [0, 1] assert pipeline.requests[0].request_id == "req-stream" @pytest.mark.asyncio @@ -136,7 +161,7 @@ class TestPipelineStreamingOutputToStageDiffusionClient: assert len(outputs) == 2 assert outputs[0].request_id == "req-error" assert outputs[0].finished is False - assert outputs[0].custom_output["chunk"] == 0 + assert outputs[0].multimodal_output["metadata"]["stream"]["chunk"] == 0 assert outputs[1].request_id == "req-error" assert outputs[1].finished is True assert outputs[1].error is not None @@ -227,8 +252,8 @@ class TestPipelineStreamingOutputToEntrypoint: """Mock pipeline streaming chunks reach AsyncOmni.generate() via inline stage and orchestrator.""" pipeline = _StepStreamingPipeline( [ - DiffusionOutput(custom_output={"chunk": 0}, finished=False), - DiffusionOutput(custom_output={"chunk": 1}, finished=True), + _streaming_diffusion_output(chunk=0, finished=False), + _streaming_diffusion_output(chunk=1, finished=True), ] ) inline_client = self._make_inline_pipeline_client(pipeline) @@ -247,7 +272,7 @@ class TestPipelineStreamingOutputToEntrypoint: await _wait_for(lambda: len(pipeline.requests) == 1) assert pipeline.requests[0].request_id.startswith("req-omni-") - assert [output.custom_output["chunk"] for output in outputs] == [0, 1] + assert [output.multimodal_output["metadata"]["stream"]["chunk"] for output in outputs] == [0, 1] assert [getattr(output.request_output, "finished", output.finished) for output in outputs] == [False, True] finally: await self._shutdown_pipeline_omni_harness(omni, fixture, inline_client) @@ -258,8 +283,8 @@ class TestPipelineStreamingOutputToEntrypoint: frames = [np.full((4, 4, 3), fill_value=i, dtype=np.uint8) for i in range(2)] pipeline = _StepStreamingPipeline( [ - DiffusionOutput(custom_output={"chunk": 0, "images": [frames]}, finished=False), - DiffusionOutput(custom_output={"chunk": 1, "images": [frames]}, finished=True), + _streaming_diffusion_output(chunk=0, images=[frames], finished=False), + _streaming_diffusion_output(chunk=1, images=[frames], finished=True), ] ) inline_client = self._make_inline_pipeline_client(pipeline) diff --git a/tests/e2e/accuracy/test_hunyuan_image3.py b/tests/e2e/accuracy/test_hunyuan_image3.py index 753050c41..00e77fc8f 100644 --- a/tests/e2e/accuracy/test_hunyuan_image3.py +++ b/tests/e2e/accuracy/test_hunyuan_image3.py @@ -331,7 +331,10 @@ def _run_offline(deploy_config_path: str, output_path: Path) -> tuple[Image.Imag if ro and getattr(ro, "outputs", None): cot_text = "".join(getattr(o, "text", "") or "" for o in ro.outputs) if not cot_text: - ar_text = getattr(out, "custom_output", {}).get("ar_generated_text") + multimodal_output = getattr(out, "multimodal_output", {}) or {} + metadata = multimodal_output.get("metadata", {}) if isinstance(multimodal_output, dict) else {} + text_metadata = metadata.get("text", {}) if isinstance(metadata, dict) else {} + ar_text = text_metadata.get("ar_generated_text") if isinstance(text_metadata, dict) else None if isinstance(ar_text, list): cot_text = "\n".join(text for text in ar_text if text) else: diff --git a/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py b/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py index 97efab0fe..193d1b1e2 100644 --- a/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py +++ b/tests/e2e/offline_inference/custom_pipeline/qwen_image_pipeline_with_logprob.py @@ -288,7 +288,7 @@ class QwenImagePipelineWithLogProbForTest(QwenImagePipeline): else: # Both prompt_ids and prompt_embeds are None (e.g. during warmup/dummy run). # Return a minimal dummy output to avoid crashing. - return DiffusionOutput(output=None, custom_output={}) + return DiffusionOutput(output=None) if isinstance(negative_prompt_ids, list): negative_prompt_ids = torch.tensor(negative_prompt_ids, device=self.device) @@ -399,14 +399,20 @@ class QwenImagePipelineWithLogProbForTest(QwenImagePipeline): image = self.vae.decode(latents, return_dict=False)[0][:, :, 0] return DiffusionOutput( - output=_maybe_to_cpu(image), + output={ + "payload": { + "image": _maybe_to_cpu(image), + }, + "metadata": { + "prompt_embeddings": { + "prompt_embeds": _maybe_to_cpu(prompt_embeds), + "prompt_embeds_mask": _maybe_to_cpu(prompt_embeds_mask), + "negative_prompt_embeds": _maybe_to_cpu(negative_prompt_embeds), + "negative_prompt_embeds_mask": _maybe_to_cpu(negative_prompt_embeds_mask), + }, + }, + }, trajectory_latents=_maybe_to_cpu(all_latents), trajectory_log_probs=_maybe_to_cpu(all_log_probs), trajectory_timesteps=_maybe_to_cpu(all_timesteps), - custom_output={ - "prompt_embeds": _maybe_to_cpu(prompt_embeds), - "prompt_embeds_mask": _maybe_to_cpu(prompt_embeds_mask), - "negative_prompt_embeds": _maybe_to_cpu(negative_prompt_embeds), - "negative_prompt_embeds_mask": _maybe_to_cpu(negative_prompt_embeds_mask), - }, ) diff --git a/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py b/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py index 9bcaf0e40..69952435e 100644 --- a/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py +++ b/tests/e2e/offline_inference/rlhf_test/test_verl_omni_e2e.py @@ -390,20 +390,23 @@ class vLLMOmniHttpServerLocal: diffusion_output = self._to_tensor(final_res.images[0]).float() / 255.0 - mm_output = final_res.custom_output or {} + metadata = ( + final_res.multimodal_output.get("metadata", {}) if isinstance(final_res.multimodal_output, dict) else {} + ) + prompt_embeddings = metadata.get("prompt_embeddings", {}) if isinstance(metadata, dict) else {} if sampling_params.get("logprobs", False): - all_log_probs = mm_output.get("all_log_probs") + all_log_probs = final_res.trajectory_log_probs log_probs = all_log_probs[0] if all_log_probs is not None else None else: log_probs = None - all_latents = mm_output.get("all_latents") - all_timesteps = mm_output.get("all_timesteps") - prompt_embeds = mm_output.get("prompt_embeds") - prompt_embeds_mask = mm_output.get("prompt_embeds_mask") - negative_prompt_embeds = mm_output.get("negative_prompt_embeds") - negative_prompt_embeds_mask = mm_output.get("negative_prompt_embeds_mask") + all_latents = final_res.trajectory_latents + all_timesteps = final_res.trajectory_timesteps + prompt_embeds = prompt_embeddings.get("prompt_embeds") + prompt_embeds_mask = prompt_embeddings.get("prompt_embeds_mask") + negative_prompt_embeds = prompt_embeddings.get("negative_prompt_embeds") + negative_prompt_embeds_mask = prompt_embeddings.get("negative_prompt_embeds_mask") extra_fields = { "all_latents": all_latents[0] if all_latents is not None else None, diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index 3f1c5f8e5..ff69a2bf2 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -44,23 +44,18 @@ class MockVideoResult: videos, audios=None, sample_rate=None, - custom_output=None, + multimodal_output=None, stage_durations=None, peak_memory_mb=0.0, ): - self.multimodal_output = {"video": videos} + self.multimodal_output = dict(multimodal_output or {"video": videos}) if audios is not None: self.multimodal_output["audio"] = audios if sample_rate is not None: self.multimodal_output["audio_sample_rate"] = sample_rate - self._custom_output = custom_output or {} self.stage_durations = stage_durations or {} self.peak_memory_mb = peak_memory_mb - @property - def custom_output(self): - return self._custom_output - class FakeAsyncOmni: def __init__(self): @@ -835,7 +830,13 @@ def test_worker_fps_multiplier_is_applied_to_async_encoding(test_client, mocker: engine.captured_sampling_params_list = sampling_params_list import numpy as np - yield MockVideoResult([np.zeros((1, 64, 64, 3), dtype=np.uint8)], custom_output={"video_fps_multiplier": 2}) + yield MockVideoResult( + [np.zeros((1, 64, 64, 3), dtype=np.uint8)], + multimodal_output={ + "video": [np.zeros((1, 64, 64, 3), dtype=np.uint8)], + "metadata": {"video": {"video_fps_multiplier": 2}}, + }, + ) engine.generate = _generate @@ -939,11 +940,16 @@ def test_video_generation_response_exposes_action_payload(mocker: MockerFixture) yield MockVideoResult( [object()], - custom_output={ - "action": np.array([[[1.5, 2.5], [3.5, 4.5]]], dtype=np.float32), - "raw_action_dim": 2, - "action_mode": "policy", - "domain_id": 7, + multimodal_output={ + "video": [object()], + "actions": np.array([[[1.5, 2.5], [3.5, 4.5]]], dtype=np.float32), + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + }, }, ) @@ -981,11 +987,16 @@ def test_video_job_persists_action_metadata(test_client, mocker: MockerFixture): engine.captured_sampling_params_list = sampling_params_list yield MockVideoResult( [object()], - custom_output={ - "action": np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32), - "raw_action_dim": 2, - "action_mode": "policy", - "domain_id": 7, + multimodal_output={ + "video": [object()], + "actions": np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32), + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + }, }, ) @@ -1019,19 +1030,51 @@ def test_action_extraction_accepts_unbatched_action(): result = MockVideoResult( [object()], - custom_output={ - "action": np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32), - "raw_action_dim": 2, - "action_mode": "policy", - "domain_id": 7, + multimodal_output={ + "video": [object()], + "actions": np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32), + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + }, }, ) actions = OmniOpenAIServingVideo._extract_action_outputs(result, expected_count=1) + assert actions and actions[0] is not None + assert actions[0].data == [[1.0, 2.0], [3.0, 4.0]] + assert actions[0].shape == [2, 2] + + +def test_action_extraction_accepts_multimodal_actions_payload(): + import numpy as np + + result = MockVideoResult([object()]) + result.multimodal_output.update( + { + "actions": np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32), + "metadata": { + "actions": { + "raw_action_dim": 2, + "action_mode": "policy", + "domain_id": 7, + }, + }, + } + ) + + actions = OmniOpenAIServingVideo._extract_action_outputs(result, expected_count=1) + assert actions[0] is not None assert actions[0].data == [[1.0, 2.0], [3.0, 4.0]] assert actions[0].shape == [2, 2] + assert actions[0].raw_action_dim == 2 + assert actions[0].action_mode == "policy" + assert actions[0].domain_id == 7 def test_missing_handler_returns_503(): @@ -1840,7 +1883,13 @@ def test_worker_fps_multiplier_is_applied_to_sync_encoding(test_client, mocker: async def _generate(prompt, request_id, sampling_params_list): engine.captured_prompt = prompt engine.captured_sampling_params_list = sampling_params_list - yield MockVideoResult([object()], custom_output={"video_fps_multiplier": 2}) + yield MockVideoResult( + [object()], + multimodal_output={ + "video": [object()], + "metadata": {"video": {"video_fps_multiplier": 2}}, + }, + ) engine.generate = _generate diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 3d7e1bcfc..0feab5b28 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -1198,8 +1198,10 @@ class DiffusionOutput: Final output (after pipeline completion) """ - # Fields may be replaced with SHM handle dicts by ipc.pack_diffusion_output_shm output: torch.Tensor | tuple[Any, ...] | dict[str, Any] | None = None + + # Legacy compatibility fields. New pipeline-specific payloads should be + # carried by output["payload"] instead. trajectory_timesteps: torch.Tensor | dict[str, Any] | None = None trajectory_latents: torch.Tensor | dict[str, Any] | None = None trajectory_log_probs: torch.Tensor | dict[str, Any] | None = None @@ -1212,10 +1214,6 @@ class DiffusionOutput: post_process_func: Callable[..., Any] | None = None - # Extra custom output data (e.g. latent trajectories, prompt embeds) - # passed through to OmniRequestOutput.custom_output - custom_output: dict[str, Any] = field(default_factory=dict) - # logged timings info, directly from Req.timings # timings: Optional["RequestTimings"] = None @@ -1230,10 +1228,9 @@ class DiffusionOutput: # memory usage info peak_memory_mb: float = 0.0 - # When True, move all tensor fields (including tensors inside - # ``custom_output``) to CPU at construction time. Useful when the output - # is shipped across process boundaries (e.g. step-execution mode) and the - # receiving side must not initialise a stray CUDA context. + # When True, move tensor fields to CPU at construction time. Useful when + # the output is shipped across process boundaries (e.g. step-execution + # mode) and the receiving side must not initialise a stray CUDA context. to_cpu: bool = False def __post_init__(self) -> None: @@ -1243,14 +1240,18 @@ class DiffusionOutput: def _maybe_to_cpu(value: Any) -> Any: if isinstance(value, torch.Tensor): return value.detach().cpu() + if isinstance(value, dict): + return {key: _maybe_to_cpu(item) for key, item in value.items()} + if isinstance(value, list): + return [_maybe_to_cpu(item) for item in value] + if isinstance(value, tuple): + return tuple(_maybe_to_cpu(item) for item in value) return value self.output = _maybe_to_cpu(self.output) self.trajectory_timesteps = _maybe_to_cpu(self.trajectory_timesteps) self.trajectory_latents = _maybe_to_cpu(self.trajectory_latents) self.trajectory_log_probs = _maybe_to_cpu(self.trajectory_log_probs) - if self.custom_output: - self.custom_output = {k: _maybe_to_cpu(v) for k, v in self.custom_output.items()} @classmethod def from_exception(cls, exc: BaseException) -> "DiffusionOutput": diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index f552ae293..d6e7b35de 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -10,7 +10,7 @@ import queue import threading import time from collections.abc import AsyncGenerator, Iterable -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any import numpy as np @@ -40,7 +40,6 @@ from vllm_omni.diffusion.output_formatter import ( ) from vllm_omni.diffusion.registry import ( DiffusionModelRegistry, - get_diffusion_action_post_process_func, get_diffusion_post_process_func, get_diffusion_pre_process_func, ) @@ -155,17 +154,10 @@ class DiffusionEngine: self.od_config = od_config self.post_process_func = get_diffusion_post_process_func(od_config) - self.action_post_process_func = get_diffusion_action_post_process_func(od_config) self.pre_process_func = get_diffusion_pre_process_func(od_config) # Cache whether the model-specific postprocess accepts request-level # sampling params so step() can support both legacy and extended hooks. self._post_process_accepts_sampling_params = _func_accepts_parameter(self.post_process_func, "sampling_params") - self._action_post_process_accepts_sampling_params = _func_accepts_parameter( - self.action_post_process_func, "sampling_params" - ) - self._action_post_process_accepts_custom_output = _func_accepts_parameter( - self.action_post_process_func, "custom_output" - ) self.step_execution = bool(getattr(od_config, "step_execution", False)) if self.od_config.streaming_output and not self.step_execution: @@ -307,46 +299,18 @@ class DiffusionEngine: if self.od_config.enable_cpu_offload: output_data = _move_tensor_tree_to_cpu(output_data) - custom_output = output.custom_output or {} - action_payload = None - action_only_output = bool(custom_output.get("action_only_output")) - postprocess_start_time = time.perf_counter() - if action_only_output: - outputs = [] - elif self.post_process_func is not None: + if self.post_process_func is not None: # Some video pipelines need request-level controls during # postprocess (for example worker-side frame interpolation). + postprocess_kwargs: dict[str, object] = {} if self._post_process_accepts_sampling_params: - outputs = self.post_process_func(output_data, sampling_params=request.sampling_params) - else: - outputs = self.post_process_func(output_data) + postprocess_kwargs["sampling_params"] = request.sampling_params + outputs = self.post_process_func(output_data, **postprocess_kwargs) else: outputs = output_data - postprocess_output = normalize_diffusion_postprocess_output(outputs, custom_output) - custom_output = postprocess_output.custom_output - action_payload = postprocess_output.action_payload - if action_payload is None: - action_payload = custom_output.get("actions") - if action_payload is not None: - postprocess_output = replace(postprocess_output, action_payload=action_payload) - action_post_process_func = getattr(self, "action_post_process_func", None) - if action_payload is None and action_post_process_func is not None: - raw_action_payload = custom_output.get("action") - if raw_action_payload is not None: - action_kwargs: dict[str, Any] = {} - if getattr(self, "_action_post_process_accepts_custom_output", False): - action_kwargs["custom_output"] = custom_output - if getattr(self, "_action_post_process_accepts_sampling_params", False): - action_kwargs["sampling_params"] = request.sampling_params - action_payload = action_post_process_func(raw_action_payload, **action_kwargs) - custom_output = {**custom_output, "actions": action_payload} - postprocess_output = replace( - postprocess_output, - custom_output=custom_output, - action_payload=action_payload, - ) + postprocess_output = normalize_diffusion_postprocess_output(outputs) postprocess_time = time.perf_counter() - postprocess_start_time logger.debug("Post-processing completed in %.4f seconds", postprocess_time) diff --git a/vllm_omni/diffusion/models/audiox/pipeline_audiox.py b/vllm_omni/diffusion/models/audiox/pipeline_audiox.py index 02639f1af..395e06dc8 100644 --- a/vllm_omni/diffusion/models/audiox/pipeline_audiox.py +++ b/vllm_omni/diffusion/models/audiox/pipeline_audiox.py @@ -60,6 +60,15 @@ def get_audiox_post_process_func(od_config: OmniDiffusionConfig): """Convert the pipeline's float audio tensor to a CPU numpy array for serving.""" def post_process_func(audio: torch.Tensor) -> Any: + if isinstance(audio, dict) and isinstance(audio.get("payload"), dict): + payload = dict(audio["payload"]) + audio_payload = payload.get("audio") + if isinstance(audio_payload, torch.Tensor): + payload["audio"] = audio_payload.detach().cpu().float().numpy() + return { + "payload": payload, + "metadata": audio.get("metadata") or {}, + } if isinstance(audio, torch.Tensor): return audio.detach().cpu().float().numpy() return audio @@ -915,9 +924,10 @@ class AudioXPipeline(nn.Module, SupportAudioOutput, DiffusionPipelineProfilerMix audio = audio[..., :target_samples] stage_durations = self.stage_durations if getattr(self, "enable_diffusion_pipeline_profiler", False) else None - custom_output = {"audiox_task": task_norm} return DiffusionOutput( - output=audio, - custom_output=custom_output, + output={ + "payload": {"audio": audio}, + "metadata": {"audio": {"audiox_task": task_norm}}, + }, stage_durations=stage_durations, ) diff --git a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py index 453622765..9786e2b47 100644 --- a/vllm_omni/diffusion/models/bagel/pipeline_bagel.py +++ b/vllm_omni/diffusion/models/bagel/pipeline_bagel.py @@ -739,8 +739,10 @@ class BagelPipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelineProf text_output = text_output.split("<|im_start|>")[-1] return DiffusionOutput( - output=text_output, - custom_output={"text_output": text_output}, + output={ + "payload": {"text": text_output}, + "metadata": {"text": {"text_output": text_output}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -847,22 +849,28 @@ class BagelPipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipelineProf if trajectory_log_probs: trajectory_log_probs_stacked = torch.stack(trajectory_log_probs) - custom = {} + payload = {"image": img} + metadata = {} if think_text is not None: - custom["think_text"] = think_text - # Mirror the PIL image into ``custom_output`` so callers reading via - # the orchestrator IPC boundary (which strips the bare ``output`` - # field) can still recover the result. ``video_frames`` already - # uses this pattern. - custom["image"] = img + metadata["text"] = {"think_text": think_text} + trajectory_payload = {} + if trajectory_latents_stacked is not None: + trajectory_payload["latents"] = trajectory_latents_stacked + if trajectory_timesteps_stacked is not None: + trajectory_payload["timesteps"] = trajectory_timesteps_stacked + if trajectory_log_probs_stacked is not None: + trajectory_payload["log_probs"] = trajectory_log_probs_stacked + if trajectory_decoded is not None: + trajectory_payload["decoded"] = trajectory_decoded + if trajectory_payload: + payload["trajectory"] = trajectory_payload + metadata["trajectory"] = {"type": "denoising"} return DiffusionOutput( - output=img, - trajectory_latents=trajectory_latents_stacked, - trajectory_timesteps=trajectory_timesteps_stacked, - trajectory_log_probs=trajectory_log_probs_stacked, - trajectory_decoded=trajectory_decoded, - custom_output=custom, + output={ + "payload": payload, + "metadata": metadata, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) diff --git a/vllm_omni/diffusion/models/cosmos3/__init__.py b/vllm_omni/diffusion/models/cosmos3/__init__.py index 43be0c37a..6df062b5c 100644 --- a/vllm_omni/diffusion/models/cosmos3/__init__.py +++ b/vllm_omni/diffusion/models/cosmos3/__init__.py @@ -3,7 +3,6 @@ from .pipeline_cosmos3 import ( Cosmos3OmniDiffusersPipeline, - get_cosmos3_action_post_process_func, get_cosmos3_post_process_func, get_cosmos3_pre_process_func, ) @@ -11,7 +10,6 @@ from .transformer_cosmos3 import Cosmos3VFMTransformer __all__ = [ "Cosmos3OmniDiffusersPipeline", - "get_cosmos3_action_post_process_func", "get_cosmos3_post_process_func", "get_cosmos3_pre_process_func", "Cosmos3VFMTransformer", diff --git a/vllm_omni/diffusion/models/cosmos3/action.py b/vllm_omni/diffusion/models/cosmos3/action.py index 27f6d36c2..c5fe41a91 100644 --- a/vllm_omni/diffusion/models/cosmos3/action.py +++ b/vllm_omni/diffusion/models/cosmos3/action.py @@ -4,8 +4,8 @@ These helpers cover action modes that use action tokens as an auxiliary output stream: ``policy``, ``forward_dynamics``, and ``inverse_dynamics``. The pipeline -returns predicted actions through ``custom_output`` rather than as a normal -``modalities`` entry. +returns predicted actions through the diffusion output payload/metadata +envelope rather than as a video frame stream. """ from __future__ import annotations diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 4153eaa67..cd8c12777 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -20,7 +20,8 @@ Video generation is further specialized by inputs and extra args: transfer video generation. * ``action_mode``: action-capable video generation. RoboLab/OpenPI observation payloads in ``extra_args["robot_obs"]`` or ``extra_args["observation"]`` - bypass normal video output and return action-only custom output. + bypass normal video output and return an action-only payload/metadata + envelope. Generated sound is video-only, cannot be combined with action or transfer, and is produced from sound latents rather than from ``multi_modal_data["audio"]``. @@ -133,6 +134,7 @@ from .utils import ( logger = init_logger(__name__) + COSMOS3_DEFAULT_CONDITION_PIXEL_FRAMES = ( max(COSMOS3_DEFAULT_CONDITION_FRAME_INDEXES_VISION) * COSMOS3_VAE_TEMPORAL_COMPRESSION + 1 ) @@ -530,6 +532,37 @@ def get_cosmos3_post_process_func(od_config: OmniDiffusionConfig): if output_type == "latent": return output + def _postprocess_action(action: Any, metadata: dict[str, Any]) -> Any: + internal_metadata = metadata.get("internal") + inputs = ( + internal_metadata.get("robolab_action_postprocess") if isinstance(internal_metadata, dict) else None + ) + if isinstance(inputs, RoboLabActionPostprocessInputs): + return postprocess_robolab_action(action, inputs) + return action + + pending_action = None + pending_action_metadata: dict[str, Any] = {} + envelope_public_metadata: dict[str, Any] = {} + if isinstance(output, dict) and isinstance(output.get("payload"), dict): + envelope_payload = dict(output.get("payload") or {}) + metadata = output.get("metadata") or {} + envelope_metadata = metadata if isinstance(metadata, dict) else {} + envelope_public_metadata = {key: value for key, value in envelope_metadata.items() if key != "internal"} + action = envelope_payload.pop("actions", None) + if action is not None: + pending_action = _postprocess_action(action, envelope_metadata) + pending_action_metadata = envelope_public_metadata + if not envelope_payload: + return { + "payload": { + "video": [], + "actions": pending_action, + }, + "metadata": pending_action_metadata, + } + output = envelope_payload + audio = None audio_sample_rate = None if isinstance(output, dict): @@ -568,11 +601,31 @@ def get_cosmos3_post_process_func(od_config: OmniDiffusionConfig): # check_video_safety expects a 5D tensor; re-add T axis. checked = check_video_safety(image.unsqueeze(2)) image = checked.squeeze(2) - return video_processor.postprocess(image, output_type="pil") - if is_guardrails_enabled(od_config, sampling_params): + processed_image = video_processor.postprocess(image, output_type="pil") + if envelope_public_metadata: + return { + "payload": {"image": processed_image}, + "metadata": envelope_public_metadata, + } + return processed_image + guardrails_enabled = is_guardrails_enabled(od_config, sampling_params) + if guardrails_enabled: video = check_video_safety(video) processed_video = video_processor.postprocess_video(video, output_type=output_type) if audio is None: + if pending_action is not None: + return { + "payload": { + "video": processed_video, + "actions": pending_action, + }, + "metadata": pending_action_metadata, + } + if envelope_public_metadata: + return { + "payload": {"video": processed_video}, + "metadata": envelope_public_metadata, + } return processed_video if isinstance(audio, torch.Tensor): audio = audio.detach().cpu() @@ -583,33 +636,38 @@ def get_cosmos3_post_process_func(od_config: OmniDiffusionConfig): } if audio_sample_rate is not None: result["audio_sample_rate"] = int(audio_sample_rate) + if pending_action is not None: + return { + "payload": { + "video": result["video"], + "audio": result["audio"], + "actions": pending_action, + }, + "metadata": { + **pending_action_metadata, + "video": {"fps": result["fps"]}, + "audio": {"sample_rate": result.get("audio_sample_rate")}, + }, + } + if envelope_public_metadata: + return { + "payload": { + "video": result["video"], + "audio": result["audio"], + }, + "metadata": { + **envelope_public_metadata, + "video": {"fps": result["fps"], **envelope_public_metadata.get("video", {})} + if isinstance(envelope_public_metadata.get("video"), dict) + else {"fps": result["fps"]}, + "audio": {"sample_rate": result.get("audio_sample_rate")}, + }, + } return result return post_process_func -def get_cosmos3_action_post_process_func(od_config: OmniDiffusionConfig): - """Build the custom-output postprocessor for Cosmos3 action predictions. - - Action modes return predicted action tensors in ``custom_output`` alongside - normal video output. RoboLab/OpenPI policy serving marks action-only output - and carries observation metadata used here to map model-space actions back - to the requested robot action representation. - """ - del od_config - - def action_post_process_func(action: Any, custom_output: dict[str, Any] | None = None, sampling_params=None): - del sampling_params - inputs = custom_output.get("robolab_action_postprocess") if isinstance(custom_output, dict) else None - if isinstance(inputs, RoboLabActionPostprocessInputs): - processed_action = postprocess_robolab_action(action, inputs) - custom_output.pop("robolab_action_postprocess", None) - return processed_action - return action - - return action_post_process_func - - def get_cosmos3_ir_op_priority_func(od_config: OmniDiffusionConfig): del od_config @@ -669,9 +727,10 @@ class Cosmos3OmniDiffusersPipeline( T2I, transfer, and action+sound are rejected. * **Action generation** when ``action_mode`` is provided. ``policy`` and ``forward_dynamics`` require an image or video input; ``inverse_dynamics`` - requires video input. Action predictions are returned in ``custom_output``. + requires video input. Action predictions are returned in the diffusion + output payload/metadata envelope. RoboLab/OpenPI observations in ``extra_args['robot_obs']`` or - ``extra_args['observation']`` return action-only custom output. + ``extra_args['observation']`` return an action-only envelope. * **T2V** otherwise (default video generation). """ @@ -1409,15 +1468,25 @@ class Cosmos3OmniDiffusersPipeline( logger.info("Total pipeline time: %.2fs", time.time() - pipeline_start) action = action_latents[:, :, :raw_action_dim].detach().cpu() - custom_action_output: dict[str, Any] = { - "action": action, - "raw_action_dim": raw_action_dim, - "action_mode": action_mode, - "domain_id": domain_id, - "action_only_output": True, - "robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs), + action_output: dict[str, Any] = { + "payload": { + "actions": action, + }, + "metadata": { + "actions": { + "raw_action_dim": raw_action_dim, + "action_mode": action_mode, + "domain_id": domain_id, + }, + "common": { + "action_only_output": True, + }, + "internal": { + "robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs), + }, + }, } - return DiffusionOutput(output={}, custom_output=custom_action_output) + return DiffusionOutput(output=action_output) @staticmethod def _truthy(value) -> bool: @@ -2881,11 +2950,15 @@ class Cosmos3OmniDiffusersPipeline( full_output = torch.cat([normalized_input.to(full_output), full_output], dim=-1) return DiffusionOutput( - output={"video": full_output}, - custom_output={ - "transfer_controls": full_controls, - "transfer_hints": list(per_hint_frames), - "fps": frame_rate, + output={ + "payload": {"video": full_output}, + "metadata": { + "transfer": { + "controls": full_controls, + "hints": list(per_hint_frames), + }, + "video": {"fps": frame_rate}, + }, }, ) @@ -3287,12 +3360,18 @@ class Cosmos3OmniDiffusersPipeline( raise ValueError("Cosmos3 action generation finished without action latents.") action = action_latents[:, :, :raw_action_dim].detach().cpu() return DiffusionOutput( - output={"video": video}, - custom_output={ - "action": action, - "raw_action_dim": raw_action_dim, - "action_mode": action_mode, - "domain_id": domain_id, + output={ + "payload": { + "video": video, + "actions": action, + }, + "metadata": { + "actions": { + "raw_action_dim": raw_action_dim, + "action_mode": action_mode, + "domain_id": domain_id, + }, + }, }, ) 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 73e9ff666..c0476b879 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py @@ -2209,7 +2209,6 @@ class HunyuanImage3Pipeline( if output_type == "latent": return DiffusionOutput( output=latents, - custom_output={}, stage_durations=getattr(self, "stage_durations", None), ) @@ -2232,12 +2231,14 @@ class HunyuanImage3Pipeline( ) cot_text_list = state.extra.get(_STEP_COT_TEXT_LIST) or [] - custom_output = {} + metadata = {} if any(text is not None for text in cot_text_list): - custom_output["ar_generated_text"] = cot_text_list[0] + metadata["text"] = {"ar_generated_text": cot_text_list[0]} return DiffusionOutput( - output=image[0], - custom_output=custom_output, + output={ + "payload": {"image": image[0]}, + "metadata": metadata, + }, stage_durations=getattr(self, "stage_durations", None), ) @@ -2302,12 +2303,14 @@ class HunyuanImage3Pipeline( outputs = self._generate(**model_inputs, **kwargs) image = outputs[0] - custom_output = {} + metadata = {} if any(t is not None for t in cot_text_list): - custom_output["ar_generated_text"] = cot_text_list[0] if len(cot_text_list) == 1 else cot_text_list + metadata["text"] = {"ar_generated_text": cot_text_list[0] if len(cot_text_list) == 1 else cot_text_list} stage_durations = self.stage_durations if hasattr(self, "stage_durations") else None return DiffusionOutput( - output=image, - custom_output=custom_output, + output={ + "payload": {"image": image}, + "metadata": metadata, + }, stage_durations=stage_durations, ) diff --git a/vllm_omni/diffusion/models/internvla_a1/pipeline_internvla_a1.py b/vllm_omni/diffusion/models/internvla_a1/pipeline_internvla_a1.py index 6c28e760c..10498620c 100644 --- a/vllm_omni/diffusion/models/internvla_a1/pipeline_internvla_a1.py +++ b/vllm_omni/diffusion/models/internvla_a1/pipeline_internvla_a1.py @@ -247,11 +247,13 @@ class InternVLAA1Pipeline(nn.Module, DiffusionPipelineProfilerMixin): noise=extra_args.get("noise"), decode_image=bool(extra_args.get("decode_image", False)), ) - custom_output: dict[str, Any] = {} + metadata: dict[str, Any] = {} if decoded is not None: - custom_output["decoded"] = decoded + metadata["actions"] = {"decoded": decoded} return DiffusionOutput( - output=output, - custom_output=custom_output, + output={ + "payload": {"actions": output}, + "metadata": metadata, + }, post_process_func=get_internvla_a1_post_process_func(self.od_config), ) diff --git a/vllm_omni/diffusion/models/lance/pipeline_lance.py b/vllm_omni/diffusion/models/lance/pipeline_lance.py index 6f7e9b95e..dc885ac44 100644 --- a/vllm_omni/diffusion/models/lance/pipeline_lance.py +++ b/vllm_omni/diffusion/models/lance/pipeline_lance.py @@ -836,8 +836,10 @@ class LancePipeline(BagelPipeline): frames = [Image.fromarray(f) for f in frames_np] logger.info("Lance t2v: decoded %d frames at %dx%d", len(frames), frames[0].width, frames[0].height) return DiffusionOutput( - output=frames[0], - custom_output={"video_frames": frames, "video_shape": video_shape}, + output={ + "payload": {"video": frames}, + "metadata": {"video": {"shape": video_shape}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -1069,13 +1071,11 @@ class LancePipeline(BagelPipeline): ) img = self._decode_image_from_latent(self.bagel, self.vae, latents[0], image_shape) - # Put the PIL image into ``custom_output`` too — the orchestrator - # serializes ``custom_output`` across the IPC boundary but strips - # the bare ``output`` field, so consumers that only see - # ``outputs[0].output`` (e.g. gradio_demo) get ``None`` otherwise. return DiffusionOutput( - output=img, - custom_output={"image": img, "image_shape": image_shape}, + output={ + "payload": {"image": img}, + "metadata": {"image": {"shape": image_shape}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -1361,8 +1361,10 @@ class LancePipeline(BagelPipeline): frames_np = self._decode_video_from_latent(self.bagel, self.vae, latents[0], out_shape) frames = [Image.fromarray(f) for f in frames_np] return DiffusionOutput( - output=frames[0], - custom_output={"video_frames": frames, "video_shape": out_shape}, + output={ + "payload": {"video": frames}, + "metadata": {"video": {"shape": out_shape}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -1622,8 +1624,10 @@ class LancePipeline(BagelPipeline): frames_np = self._decode_video_from_latent(self.bagel, self.vae, latents[0], video_shape) frames = [Image.fromarray(f) for f in frames_np] return DiffusionOutput( - output=frames[0], - custom_output={"video_frames": frames, "video_shape": video_shape}, + output={ + "payload": {"video": frames}, + "metadata": {"video": {"shape": video_shape}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -1705,8 +1709,10 @@ class LancePipeline(BagelPipeline): text_output = text_output.lstrip("\n") logger.info("Lance x2t_video: generated %d tokens", token_ids.shape[0]) return DiffusionOutput( - output=text_output, - custom_output={"text_output": text_output}, + output={ + "payload": {"text": text_output}, + "metadata": {"text": {"text_output": text_output}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) @@ -1801,8 +1807,10 @@ class LancePipeline(BagelPipeline): text_output = text_output.lstrip("\n") logger.info("Lance x2t_image: generated %d tokens", token_ids.shape[0]) return DiffusionOutput( - output=text_output, - custom_output={"text_output": text_output}, + output={ + "payload": {"text": text_output}, + "metadata": {"text": {"text_output": text_output}}, + }, stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py index 638316039..986c2ef86 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py @@ -73,8 +73,19 @@ def get_qwen_image_post_process_func( image_processor = VaeImageProcessor(vae_scale_factor=vae_scale_factor * 2) def post_process_func( - images: torch.Tensor, + images: torch.Tensor | dict[str, Any], ): + if isinstance(images, dict) and isinstance(images.get("payload"), dict): + payload = dict(images["payload"]) + image_payload = payload.get("image") + if image_payload is None: + raise ValueError("Qwen-Image postprocess expected payload['image'] in output envelope.") + payload["image"] = image_processor.postprocess(image_payload) + metadata = images.get("metadata") or {} + return { + "payload": payload, + "metadata": metadata if isinstance(metadata, dict) else {}, + } return image_processor.postprocess(images) return post_process_func 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 9c4a052ae..67e141e6a 100644 --- a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py +++ b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py @@ -938,8 +938,10 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli ) logger.info("Text generation: %d chars", len(text_output)) return DiffusionOutput( - output=text_output, - custom_output={"text_output": text_output}, + output={ + "payload": {"text": text_output}, + "metadata": {"text": {"text_output": text_output}}, + }, ) # ----------------------------------------------------------------------- @@ -1457,10 +1459,15 @@ class SenseNovaU1Pipeline(nn.Module, SupportsComponentDiscovery, DiffusionPipeli images = _to_pil(image_prediction) img = images[0] if images else None - custom = {} + metadata = {} if think_text: - custom["think_text"] = think_text - return DiffusionOutput(output=img, custom_output=custom) + metadata["text"] = {"think_text": think_text} + return DiffusionOutput( + output={ + "payload": {"image": img}, + "metadata": metadata, + } + ) # ----------------------------------------------------------------------- # Weight loading diff --git a/vllm_omni/diffusion/models/soulx_singer/modules/preprocess/pipeline.py b/vllm_omni/diffusion/models/soulx_singer/modules/preprocess/pipeline.py index aed72a995..cf5f84e7a 100644 --- a/vllm_omni/diffusion/models/soulx_singer/modules/preprocess/pipeline.py +++ b/vllm_omni/diffusion/models/soulx_singer/modules/preprocess/pipeline.py @@ -164,11 +164,11 @@ class SoulXPreprocessPipeline(nn.Module, SupportAudioInput, SupportsComponentDis sample_rate=self.target_sr, ) - # Non-None placeholder so DiffusionEngine forwards custom_output to stage-1 - # (output=None triggers an early return that drops handoff). return DiffusionOutput( - output=torch.zeros(0), - custom_output={SOULX_PREPROCESSED_KEY: payload}, + output={ + "payload": {}, + "metadata": {SOULX_PREPROCESSED_KEY: payload}, + }, to_cpu=True, ) diff --git a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_base.py b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_base.py index 64fa213ab..a48b5236d 100644 --- a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_base.py +++ b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_base.py @@ -4,6 +4,7 @@ from collections.abc import Callable from contextlib import contextmanager from typing import Any, ClassVar +import numpy as np import torch import torch.nn as nn from vllm.logger import init_logger @@ -30,6 +31,21 @@ _SOULX_PROFILER_TARGETS = [ ] +def convert_soulx_audio_output_to_numpy(audio: torch.Tensor | dict[str, Any]) -> np.ndarray | dict[str, Any]: + if isinstance(audio, dict) and isinstance(audio.get("payload"), dict): + payload = dict(audio["payload"]) + audio_payload = payload.get("audio") + if isinstance(audio_payload, torch.Tensor): + payload["audio"] = audio_payload.detach().cpu().float().numpy() + return { + "payload": payload, + "metadata": audio.get("metadata") or {}, + } + if isinstance(audio, torch.Tensor): + return audio.detach().cpu().float().numpy() + return audio + + class FlowMatchingAudioPipeline( nn.Module, CFGParallelMixin, @@ -326,7 +342,7 @@ class FlowMatchingAudioPipeline( req: OmniDiffusionRequest, *, kind: str, - custom_output_key: str, + metadata_key: str, infer_batch_fn: Callable[..., tuple[torch.Tensor, int]], prepare_extra_args: Callable[[dict[str, Any], Any], dict[str, Any]] | None = None, ) -> DiffusionOutput: @@ -353,7 +369,9 @@ class FlowMatchingAudioPipeline( ) return DiffusionOutput( - output=audio, - custom_output={custom_output_key: pitch_shift}, + output={ + "payload": {"audio": audio}, + "metadata": {"audio": {metadata_key: pitch_shift}}, + }, stage_durations=self._profiler_stage_durations() or {}, ) diff --git a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svc.py b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svc.py index 829256134..85fed9ee7 100644 --- a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svc.py +++ b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svc.py @@ -19,6 +19,7 @@ from vllm_omni.diffusion.models.soulx_singer.modules import ( ) from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_base import ( FlowMatchingAudioPipeline, + convert_soulx_audio_output_to_numpy, ) from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import ( SOULX_PREPROCESSED_KEY, @@ -96,7 +97,7 @@ def get_soulxsinger_svc_pre_process_func(od_config: OmniDiffusionConfig): def get_soulxsinger_post_process_func(od_config: OmniDiffusionConfig): def post_process_func(audio: torch.Tensor): - return audio.detach().cpu().float().numpy() + return convert_soulx_audio_output_to_numpy(audio) return post_process_func @@ -400,7 +401,7 @@ class PipelineSoulXSingerSVC(FlowMatchingAudioPipeline): return self._forward_batch_from_request( req, kind="svc", - custom_output_key="pitch_shift", + metadata_key="pitch_shift", infer_batch_fn=self.infer_svc_batch, ) diff --git a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svs.py b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svs.py index 9be70e651..fb2c086f2 100644 --- a/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svs.py +++ b/vllm_omni/diffusion/models/soulx_singer/pipeline_soulx_singer_svs.py @@ -15,6 +15,7 @@ from vllm_omni.diffusion.models.soulx_singer.modules import ( ) from vllm_omni.diffusion.models.soulx_singer.pipeline_soulx_singer_base import ( FlowMatchingAudioPipeline, + convert_soulx_audio_output_to_numpy, ) from vllm_omni.diffusion.models.soulx_singer.preprocess.payload import ( SOULX_PREPROCESSED_KEY, @@ -97,7 +98,7 @@ def get_soulxsinger_post_process_func(od_config: OmniDiffusionConfig): """Convert pipeline audio tensor output for offline consumers.""" def post_process_func(audio: torch.Tensor): - return audio.detach().cpu().float().numpy() + return convert_soulx_audio_output_to_numpy(audio) return post_process_func @@ -343,7 +344,7 @@ class PipelineSoulXSingerSVS(FlowMatchingAudioPipeline): return self._forward_batch_from_request( req, kind="svs", - custom_output_key="f0_shift", + metadata_key="f0_shift", infer_batch_fn=self.infer_svs_batch, prepare_extra_args=lambda extra_args, _sampling: normalize_svs_control_extra_args(extra_args), ) diff --git a/vllm_omni/diffusion/models/soulx_singer/preprocess/payload.py b/vllm_omni/diffusion/models/soulx_singer/preprocess/payload.py index 83f81e481..ff4e4b7ca 100644 --- a/vllm_omni/diffusion/models/soulx_singer/preprocess/payload.py +++ b/vllm_omni/diffusion/models/soulx_singer/preprocess/payload.py @@ -13,7 +13,7 @@ SOULX_PRECOMPUTED_KEYS_BY_KIND: dict[str, tuple[str, ...]] = { # Key under which the SoulX preprocess payload is stored in -# prompt["additional_information"] or DiffusionOutput.custom_output. +# prompt["additional_information"] or diffusion output metadata. SOULX_PREPROCESSED_KEY = "soulx_preprocessed" diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py index 5d7c8a281..d3726e087 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py @@ -187,7 +187,7 @@ def get_wan22_post_process_func( ): if output_type == "latent": return video - custom_output = {} + video_metadata = {} if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False): video, multiplier = interpolate_video_tensor( video, @@ -195,10 +195,10 @@ def get_wan22_post_process_func( scale=sampling_params.frame_interpolation_scale, model_path=sampling_params.frame_interpolation_model_path, ) - custom_output["video_fps_multiplier"] = multiplier + video_metadata["video_fps_multiplier"] = multiplier return { - "video": video_processor.postprocess_video(video, output_type=output_type), - "custom_output": custom_output, + "payload": {"video": video_processor.postprocess_video(video, output_type=output_type)}, + "metadata": {"video": video_metadata} if video_metadata else {}, } return post_process_func diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py index 65f74b7f9..8b042b595 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2_i2v.py @@ -65,7 +65,7 @@ def get_wan22_i2v_post_process_func( ): if output_type == "latent": return video - custom_output = {} + video_metadata = {} if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False): video, multiplier = interpolate_video_tensor( video, @@ -73,10 +73,10 @@ def get_wan22_i2v_post_process_func( scale=sampling_params.frame_interpolation_scale, model_path=sampling_params.frame_interpolation_model_path, ) - custom_output["video_fps_multiplier"] = multiplier + video_metadata["video_fps_multiplier"] = multiplier return { - "video": video_processor.postprocess_video(video, output_type=output_type), - "custom_output": custom_output, + "payload": {"video": video_processor.postprocess_video(video, output_type=output_type)}, + "metadata": {"video": video_metadata} if video_metadata else {}, } return post_process_func diff --git a/vllm_omni/diffusion/output_formatter.py b/vllm_omni/diffusion/output_formatter.py index 0611c6b04..a36305fce 100644 --- a/vllm_omni/diffusion/output_formatter.py +++ b/vllm_omni/diffusion/output_formatter.py @@ -3,8 +3,9 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TypeGuard from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.io_support import supports_audio_output @@ -12,6 +13,18 @@ from vllm_omni.diffusion.registry import DiffusionModelRegistry from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.inputs.data import OmniPromptType from vllm_omni.outputs import OmniRequestOutput +from vllm_omni.outputs.output_metadata import ( + DiffusionMetadata, + DiffusionMultimodalOutput, + DiffusionOutputEnvelope, + DiffusionPayload, + DiffusionPayloadValue, + DiffusionPostprocessRawOutput, + DiffusionTrajectoryPayload, + strip_internal_metadata, + validate_diffusion_metadata, + validate_public_diffusion_metadata, +) @dataclass(frozen=True) @@ -24,53 +37,79 @@ class DiffusionStepTimings: @dataclass(frozen=True) class DiffusionPostprocessOutput: - outputs: Any - custom_output: dict[str, Any] - audio_payload: Any | None = None - action_payload: Any | None = None - audio_sample_rate: int | None = None - fps: float | None = None - has_video_payload: bool = False + outputs: DiffusionPayload + metadata: DiffusionMetadata = field(default_factory=dict) + primary_key: str | None = None + + +def _is_output_envelope(outputs: DiffusionPostprocessRawOutput) -> TypeGuard[DiffusionOutputEnvelope]: + return isinstance(outputs, dict) and isinstance(outputs.get("payload"), dict) def normalize_diffusion_postprocess_output( - outputs: Any, - custom_output: dict[str, Any], + outputs: DiffusionPostprocessRawOutput, ) -> DiffusionPostprocessOutput: - """Normalize the legacy postprocess dict shape used by diffusion models. + """Normalize diffusion postprocess output into payload and metadata.""" - The returned envelope owns a shallow merged copy of ``custom_output`` so - postprocess values keep legacy override precedence without mutating the - caller-owned dict. - """ + if _is_output_envelope(outputs): + payload: DiffusionPayload = outputs.get("payload") or {} + metadata = outputs.get("metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + validate_diffusion_metadata(metadata) - audio_payload = None - action_payload = None - audio_sample_rate = None - fps = None - has_video_payload = False - merged_custom_output = dict(custom_output) + public_metadata = strip_internal_metadata(metadata) + if "text" in payload and "text" not in public_metadata: + public_metadata = {**public_metadata, "text": {}} + validate_public_diffusion_metadata(public_metadata) + + return DiffusionPostprocessOutput( + outputs=payload, + metadata=public_metadata, + primary_key=_infer_primary_payload_key(payload), + ) if isinstance(outputs, dict): - has_video_payload = "video" in outputs - audio_payload = outputs.get("audio") - action_payload = outputs.get("actions") - merged_custom_output.update(outputs.get("custom_output") or {}) - audio_sample_rate = outputs.get("audio_sample_rate") - fps = outputs.get("fps") - outputs = outputs.get("video", outputs) + payload = {key: value for key, value in outputs.items() if key not in {"audio_sample_rate", "fps"}} + metadata = _metadata_from_legacy_payload(outputs) + if "text" in payload and "text" not in metadata: + metadata["text"] = {} + if metadata: + validate_public_diffusion_metadata(metadata) + return DiffusionPostprocessOutput( + outputs=payload, + metadata=metadata, + primary_key=_infer_primary_payload_key(payload), + ) return DiffusionPostprocessOutput( - outputs=outputs, - custom_output=merged_custom_output, - audio_payload=audio_payload, - action_payload=action_payload, - audio_sample_rate=audio_sample_rate, - fps=fps, - has_video_payload=has_video_payload, + outputs={"output": outputs}, + primary_key="output", ) +def _metadata_from_legacy_payload(payload: dict[str, DiffusionPayloadValue]) -> DiffusionMetadata: + metadata: DiffusionMetadata = {} + audio_sample_rate = payload.get("audio_sample_rate") + if audio_sample_rate is not None: + metadata["audio"] = {"sample_rate": audio_sample_rate} + fps = payload.get("fps") + if fps is not None: + metadata["video"] = {"fps": fps} + return metadata + + +def _infer_primary_payload_key(payload: DiffusionPayload) -> str | None: + for key in ("video", "image", "text", "audio", "output"): + if key in payload: + return key + if set(payload).issubset({"actions", "trajectory"}): + return None + if "actions" in payload: + return None + return next(iter(payload), None) + + def format_empty_diffusion_outputs( request: OmniDiffusionRequest, *, @@ -93,13 +132,14 @@ def format_diffusion_outputs( request: OmniDiffusionRequest, od_config: OmniDiffusionConfig, diffusion_output: DiffusionOutput, - output_data: Any, + output_data: DiffusionPostprocessRawOutput, postprocess_output: DiffusionPostprocessOutput, timings: DiffusionStepTimings, ) -> list[OmniRequestOutput]: """Convert a finished diffusion model output into API-facing outputs.""" - outputs = _ensure_list(postprocess_output.outputs) + primary_payload = _primary_payload(postprocess_output) + outputs = _ensure_list(primary_payload) metrics = { "preprocess_time_ms": timings.preprocess_time_s * 1000, "diffusion_engine_exec_time_ms": timings.exec_time_s * 1000, @@ -112,10 +152,10 @@ def format_diffusion_outputs( # Detect text output: when the pipeline returns a string (e.g., # SenseNova-U1 / BAGEL single-stage img2text / text2text), wrap it # as a text-type response instead of an image. - is_text_output = isinstance(output_data, str) and postprocess_output.custom_output.get("text_output") is not None + is_text_output = postprocess_output.primary_key == "text" or "text" in postprocess_output.metadata is_audio_output = supports_audio_output(od_config.model_class_name) - audio_sample_rate = postprocess_output.audio_sample_rate + audio_sample_rate = _metadata_audio_sample_rate(postprocess_output.metadata) if is_audio_output and audio_sample_rate is None: model_cls = DiffusionModelRegistry._try_load_model_cls(od_config.model_class_name) audio_sample_rate = getattr(model_cls, "audio_sample_rate", None) @@ -134,14 +174,42 @@ def format_diffusion_outputs( ) -def _ensure_list(outputs: Any) -> list[Any]: +def _ensure_list(outputs: DiffusionPayloadValue) -> list[DiffusionPayloadValue]: if isinstance(outputs, list): return outputs return [outputs] if outputs is not None else [] -def _format_audio_multimodal_output(payload: Any, audio_sample_rate: int | None) -> dict[str, Any]: - mm_output: dict[str, Any] = {"audio": payload} +def _primary_payload(postprocess_output: DiffusionPostprocessOutput) -> DiffusionPayloadValue | None: + if postprocess_output.primary_key is None: + return [] + return postprocess_output.outputs.get(postprocess_output.primary_key) + + +def _metadata_audio_sample_rate(metadata: DiffusionMetadata) -> int | None: + audio_metadata = metadata.get("audio") + if not isinstance(audio_metadata, dict): + return None + sample_rate = audio_metadata.get("sample_rate") + return sample_rate if isinstance(sample_rate, int) else None + + +def _metadata_video_fps(metadata: DiffusionMetadata) -> float | None: + video_metadata = metadata.get("video") + if not isinstance(video_metadata, dict): + return None + fps = video_metadata.get("fps") + return fps if isinstance(fps, (int, float)) and not isinstance(fps, bool) else None + + +def _format_audio_multimodal_output( + payload: DiffusionPayloadValue, + audio_sample_rate: int | None, + metadata: DiffusionMetadata | None = None, +) -> DiffusionMultimodalOutput: + mm_output: DiffusionMultimodalOutput = {"audio": payload} + if metadata: + mm_output["metadata"] = metadata if audio_sample_rate is not None: mm_output["audio_sample_rate"] = audio_sample_rate return mm_output @@ -149,25 +217,29 @@ def _format_audio_multimodal_output(payload: Any, audio_sample_rate: int | None) def _has_non_audio_postprocess_payload(postprocess_output: DiffusionPostprocessOutput) -> bool: return ( - postprocess_output.has_video_payload - or postprocess_output.action_payload is not None - or postprocess_output.fps is not None + "video" in postprocess_output.outputs + or "image" in postprocess_output.outputs + or "text" in postprocess_output.metadata + or "actions" in postprocess_output.outputs + or "trajectory" in postprocess_output.outputs + or _metadata_video_fps(postprocess_output.metadata) is not None ) def _build_multimodal_output( postprocess_output: DiffusionPostprocessOutput, audio_sample_rate: int | None, -) -> dict[str, Any]: - mm_output: dict[str, Any] = {} - if postprocess_output.audio_payload is not None: - mm_output["audio"] = postprocess_output.audio_payload +) -> DiffusionMultimodalOutput: + mm_output: DiffusionMultimodalOutput = {} + if postprocess_output.metadata: + mm_output["metadata"] = postprocess_output.metadata + for key, value in postprocess_output.outputs.items(): + if key in {"audio", "actions", "trajectory"}: + mm_output[key] = value if audio_sample_rate is not None: mm_output["audio_sample_rate"] = audio_sample_rate - if postprocess_output.fps is not None: - mm_output["fps"] = postprocess_output.fps - if postprocess_output.action_payload is not None: - mm_output["actions"] = postprocess_output.action_payload + if (fps := _metadata_video_fps(postprocess_output.metadata)) is not None: + mm_output["fps"] = fps return mm_output @@ -176,8 +248,8 @@ def _format_single_prompt_output( request: OmniDiffusionRequest, prompt: OmniPromptType, diffusion_output: DiffusionOutput, - outputs: list[Any], - metrics: dict[str, Any], + outputs: list[DiffusionPayloadValue], + metrics: dict[str, object], postprocess_output: DiffusionPostprocessOutput, is_text_output: bool, is_audio_output: bool, @@ -186,6 +258,13 @@ def _format_single_prompt_output( ) -> list[OmniRequestOutput]: request_id = request.request_id mm_output = _build_multimodal_output(postprocess_output, audio_sample_rate) + if is_text_output: + mm_output["text"] = outputs[0] if len(outputs) == 1 else outputs + trajectory_payload = _trajectory_payload(postprocess_output, diffusion_output) + trajectory_latents = trajectory_payload.get("latents") + trajectory_timesteps = trajectory_payload.get("timesteps") + trajectory_log_probs = trajectory_payload.get("log_probs") + trajectory_decoded = trajectory_payload.get("decoded") if is_text_output: return [ @@ -194,7 +273,6 @@ def _format_single_prompt_output( images=[], prompt=prompt, metrics=metrics, - custom_output=postprocess_output.custom_output, multimodal_output=mm_output, final_output_type="text", stage_durations=diffusion_output.stage_durations, @@ -204,7 +282,7 @@ def _format_single_prompt_output( ] if is_audio_output and not _has_non_audio_postprocess_payload(postprocess_output): - request_audio_payload = postprocess_output.audio_payload + request_audio_payload = postprocess_output.outputs.get("audio") if request_audio_payload is None: request_audio_payload = outputs[0] if len(outputs) == 1 else outputs return [ @@ -213,14 +291,15 @@ def _format_single_prompt_output( images=[], prompt=prompt, metrics=metrics, - latents=diffusion_output.trajectory_latents, - trajectory_latents=diffusion_output.trajectory_latents, - trajectory_timesteps=diffusion_output.trajectory_timesteps, - trajectory_log_probs=diffusion_output.trajectory_log_probs, - trajectory_decoded=diffusion_output.trajectory_decoded, + latents=trajectory_latents, + trajectory_latents=trajectory_latents, + trajectory_timesteps=trajectory_timesteps, + trajectory_log_probs=trajectory_log_probs, + trajectory_decoded=trajectory_decoded, multimodal_output=_format_audio_multimodal_output( request_audio_payload, audio_sample_rate, + postprocess_output.metadata, ), final_output_type="audio", stage_durations=diffusion_output.stage_durations, @@ -235,15 +314,43 @@ def _format_single_prompt_output( images=outputs, prompt=prompt, metrics=metrics, - latents=diffusion_output.trajectory_latents, - trajectory_latents=diffusion_output.trajectory_latents, - trajectory_timesteps=diffusion_output.trajectory_timesteps, - trajectory_log_probs=diffusion_output.trajectory_log_probs, - trajectory_decoded=diffusion_output.trajectory_decoded, - custom_output=postprocess_output.custom_output, + latents=trajectory_latents, + trajectory_latents=trajectory_latents, + trajectory_timesteps=trajectory_timesteps, + trajectory_log_probs=trajectory_log_probs, + trajectory_decoded=trajectory_decoded, multimodal_output=mm_output, stage_durations=diffusion_output.stage_durations, peak_memory_mb=diffusion_output.peak_memory_mb, finished=finished, ), ] + + +def _trajectory_payload( + postprocess_output: DiffusionPostprocessOutput, + diffusion_output: DiffusionOutput, +) -> DiffusionTrajectoryPayload: + trajectory: DiffusionTrajectoryPayload = {} + payload = postprocess_output.outputs.get("trajectory") + if isinstance(payload, Mapping): + for source_key, target_key in ( + ("latents", "latents"), + ("timesteps", "timesteps"), + ("log_probs", "log_probs"), + ("decoded", "decoded"), + ): + value = payload.get(source_key) + if value is not None: + trajectory[target_key] = value + + fallback_fields = ( + ("latents", diffusion_output.trajectory_latents), + ("timesteps", diffusion_output.trajectory_timesteps), + ("log_probs", diffusion_output.trajectory_log_probs), + ("decoded", diffusion_output.trajectory_decoded), + ) + for key, value in fallback_fields: + if key not in trajectory and value is not None: + trajectory[key] = value + return trajectory diff --git a/vllm_omni/diffusion/output_metadata.py b/vllm_omni/diffusion/output_metadata.py new file mode 100644 index 000000000..abea8759e --- /dev/null +++ b/vllm_omni/diffusion/output_metadata.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Compatibility imports for diffusion output metadata. + +The shared output payload and metadata schema lives in +``vllm_omni.outputs.output_metadata``. Keep this module as a stable import +path for existing diffusion callers. +""" + +from vllm_omni.outputs.output_metadata import * # noqa: F403 diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index c8a5ee974..1454a37b1 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -541,13 +541,6 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "Krea2Pipeline": "get_krea2_post_process_func", } -_DIFFUSION_ACTION_POST_PROCESS_FUNCS = { - # arch: action_post_process_func - # `action_post_process_func` function must be placed in {mod_folder}/{mod_relname}.py, - # where mod_folder and mod_relname are defined and mapped using `_DIFFUSION_MODELS` via the `arch` key. - "Cosmos3OmniDiffusersPipeline": "get_cosmos3_action_post_process_func", -} - _DIFFUSION_IR_OP_PRIORITY_FUNCS = { # arch: ir_op_priority_func # `ir_op_priority_func` function must be placed in {mod_folder}/{mod_relname}.py, @@ -588,8 +581,8 @@ def register_diffusion_model( class_name: str, pre_process_func_name: str | None = None, post_process_func_name: str | None = None, - action_post_process_func_name: str | None = None, ir_op_priority_func_name: str | None = None, + action_post_process_func_name: str | None = None, ) -> None: """Register a diffusion model pipeline from an out-of-tree plugin. @@ -608,13 +601,23 @@ def register_diffusion_model( post_process_func_name: Optional name of the post-process function located in *module_name*. Pass ``None`` to keep the existing entry when replacing a built-in model. - action_post_process_func_name: Optional name of the action post-process - function located in *module_name*. Pass ``None`` to keep the - existing entry when replacing a built-in model. ir_op_priority_func_name: Optional name of the IR op priority merge function located in *module_name*. Pass ``None`` to keep the existing entry when replacing a built-in model. + action_post_process_func_name: Deprecated compatibility-only keyword + for out-of-tree plugins. Action postprocess hooks are no longer + registered separately; move action handling into + ``post_process_func_name`` and return a payload/metadata envelope. """ + if action_post_process_func_name is not None: + logger.warning( + "Ignoring deprecated action_post_process_func_name=%r for diffusion " + "model %s. Move action postprocess logic into post_process_func_name " + "and return payload/metadata output.", + action_post_process_func_name, + model_arch, + ) + # Register model class in DiffusionModelRegistry DiffusionModelRegistry.register_model( model_arch, @@ -631,8 +634,6 @@ def register_diffusion_model( _DIFFUSION_PRE_PROCESS_FUNCS[model_arch] = pre_process_func_name if post_process_func_name is not None: _DIFFUSION_POST_PROCESS_FUNCS[model_arch] = post_process_func_name - if action_post_process_func_name is not None: - _DIFFUSION_ACTION_POST_PROCESS_FUNCS[model_arch] = action_post_process_func_name if ir_op_priority_func_name is not None: _DIFFUSION_IR_OP_PRIORITY_FUNCS[model_arch] = ir_op_priority_func_name @@ -665,13 +666,6 @@ def get_diffusion_post_process_func(od_config: OmniDiffusionConfig): return _load_process_func(od_config, func_name) -def get_diffusion_action_post_process_func(od_config: OmniDiffusionConfig): - if od_config.model_class_name not in _DIFFUSION_ACTION_POST_PROCESS_FUNCS: - return None - func_name = _DIFFUSION_ACTION_POST_PROCESS_FUNCS[od_config.model_class_name] - return _load_process_func(od_config, func_name) - - def get_diffusion_ir_op_priority_func(od_config: OmniDiffusionConfig): if od_config.model_class_name not in _DIFFUSION_IR_OP_PRIORITY_FUNCS: return None diff --git a/vllm_omni/engine/output_modality.py b/vllm_omni/engine/output_modality.py index ac72910dd..2536a8ade 100644 --- a/vllm_omni/engine/output_modality.py +++ b/vllm_omni/engine/output_modality.py @@ -9,9 +9,6 @@ from __future__ import annotations import re from enum import Enum, Flag, StrEnum, auto -from typing import Literal, TypeAlias - -FinalOutputModalityType: TypeAlias = Literal["text", "image", "audio", "video"] _MODALITY_ALIASES: dict[str, str] = { "speech": "audio", diff --git a/vllm_omni/engine/stage_client.py b/vllm_omni/engine/stage_client.py index 276775f66..6db090955 100644 --- a/vllm_omni/engine/stage_client.py +++ b/vllm_omni/engine/stage_client.py @@ -11,8 +11,8 @@ if TYPE_CHECKING: from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType, OmniTokensPrompt from vllm_omni.outputs import OmniRequestOutput -from vllm_omni.engine.output_modality import FinalOutputModalityType from vllm_omni.inputs.data import OmniSamplingParams +from vllm_omni.outputs.output_metadata import FinalOutputModalityType class StageClient(Protocol): diff --git a/vllm_omni/engine/stage_runtime.py b/vllm_omni/engine/stage_runtime.py index 48afad0e5..8897043ca 100644 --- a/vllm_omni/engine/stage_runtime.py +++ b/vllm_omni/engine/stage_runtime.py @@ -32,7 +32,6 @@ from vllm_omni.engine.messages import ( EngineQueueMessage, RegisterRemoteReplicaMessage, ) -from vllm_omni.engine.output_modality import FinalOutputModalityType from vllm_omni.engine.stage_client import StageClient, StagePoolClient from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClientBase from vllm_omni.engine.stage_engine_startup import ( @@ -63,6 +62,7 @@ from vllm_omni.engine.stage_init_utils import ( from vllm_omni.engine.stage_pool import StagePool from vllm_omni.entrypoints.stage_utils import resolve_stage_physical_devices from vllm_omni.entrypoints.utils import inject_omni_kv_config +from vllm_omni.outputs.output_metadata import FinalOutputModalityType from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py index 6896e3641..a1a51c303 100755 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -120,6 +120,7 @@ from vllm_omni.entrypoints.openai.utils import ( from vllm_omni.errors import OmniClientError from vllm_omni.lora.request import LoRARequest from vllm_omni.outputs import OmniRequestOutput +from vllm_omni.outputs.output_metadata import DiffusionMetadataMapping, DiffusionMetadataValue from vllm_omni.utils.audio import audio_chunk_pcm_bytes, audio_chunk_sample_rate logger = init_logger(__name__) @@ -230,11 +231,15 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): def _get_diffusion_extra_output_params( self, - custom_output: dict[str, Any] | None, - ) -> dict[str, Any] | None: - """Pick model-specific extra output keys from *custom_output*.""" - if not custom_output: - return None + output: object, + ) -> dict[str, DiffusionMetadataValue] | None: + """Pick model-specific extra output keys from diffusion metadata.""" + metadata: DiffusionMetadataMapping = {} + mm_output = getattr(output, "multimodal_output", None) + if isinstance(mm_output, dict): + raw_metadata = mm_output.get("metadata") + if isinstance(raw_metadata, dict): + metadata = raw_metadata if self._diffusion_extra_output_params is None: params: frozenset[str] = frozenset() @@ -248,9 +253,20 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): if not self._diffusion_extra_output_params: return None - out = {k: custom_output[k] for k in self._diffusion_extra_output_params if k in custom_output} + flat_metadata: dict[str, DiffusionMetadataValue] = {} + for section in metadata.values(): + if isinstance(section, dict): + flat_metadata.update(section) + out = {k: flat_metadata[k] for k in self._diffusion_extra_output_params if k in flat_metadata} return out or None + @staticmethod + def _get_diffusion_text_output(output: object) -> str: + mm_output = getattr(output, "multimodal_output", None) + if isinstance(mm_output, dict) and mm_output.get("text") is not None: + return str(mm_output["text"]) + return "" + def _get_supported_speakers(self) -> set[str]: """Load supported speakers from model config (cached).""" if self._supported_speakers is not None: @@ -2167,8 +2183,8 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): else: # Diffusion pipeline text output (e.g. single-stage # img2text / text2text) — no AR request_output, so build - # a simple text choice from custom_output. - text_body = (omni_outputs.custom_output or {}).get("text_output", "") + # a simple text choice from diffusion multimodal output. + text_body = self._get_diffusion_text_output(omni_outputs) message = ChatMessage(role=role, content=text_body) choices_data = [ ChatCompletionResponseChoice( @@ -2194,7 +2210,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): response_metrics = {} response_metrics.setdefault("stage_durations", omni_outputs.stage_durations or {}) response_metrics.setdefault("peak_memory_mb", float(omni_outputs.peak_memory_mb or 0.0)) - extra = self._get_diffusion_extra_output_params(omni_outputs.custom_output) + extra = self._get_diffusion_extra_output_params(omni_outputs) if extra: response_metrics.update(extra) choices.extend(choices_data) @@ -3428,7 +3444,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): # Text output path (img2text / text2text) if is_text_request and result.final_output_type == "text": - text_body = (result.custom_output or {}).get("text_output", "") + text_body = self._get_diffusion_text_output(result) message = ChatMessage(role="assistant", content=text_body) choice = ChatCompletionResponseChoice( index=0, @@ -3447,7 +3463,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): completion_tokens=len(text_body.split()), total_tokens=len(prompt.split()) + len(text_body.split()), ), - metrics=self._get_diffusion_extra_output_params(result.custom_output), + metrics=self._get_diffusion_extra_output_params(result), ) logger.info( "Diffusion chat completed for request %s: text output (%d chars)", @@ -3580,7 +3596,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): completion_tokens=1, total_tokens=len(prompt.split()) + 1, ), - metrics=self._get_diffusion_extra_output_params(result.custom_output), + metrics=self._get_diffusion_extra_output_params(result), ) logger.info( diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py index 3e2927a27..ed846e7e8 100644 --- a/vllm_omni/entrypoints/openai/serving_video.py +++ b/vllm_omni/entrypoints/openai/serving_video.py @@ -29,6 +29,11 @@ from vllm_omni.entrypoints.openai.stage_params import ( from vllm_omni.entrypoints.openai.utils import get_stage_type, parse_lora_request from vllm_omni.entrypoints.openai.video_api_utils import _encode_video_bytes, encode_video_base64 from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt +from vllm_omni.outputs.output_metadata import ( + DiffusionMetadataMapping, + DiffusionMultimodalOutput, + DiffusionPayloadValue, +) logger = init_logger(__name__) @@ -58,8 +63,8 @@ class ReferenceAudio: class VideoGenerationArtifacts: """Normalized outputs and profiler metadata extracted from one request.""" - videos: list[Any] - audios: list[Any | None] + videos: list[DiffusionPayloadValue] + audios: list[DiffusionPayloadValue | None] actions: list[VideoAction | None] audio_sample_rate: int output_fps: int @@ -225,8 +230,10 @@ class OmniOpenAIServingVideo: ) result = await self._run_generation(prompt, gen_params, reference_id) - custom_output = self._extract_custom_output(result) - action_only = isinstance(custom_output, dict) and bool(custom_output.get("action_only_output")) + multimodal_output = self._extract_multimodal_output(result) + metadata = multimodal_output.get("metadata") if isinstance(multimodal_output, dict) else {} + common_metadata = metadata.get("common") if isinstance(metadata, dict) else {} + action_only = bool(isinstance(common_metadata, dict) and common_metadata.get("action_only_output")) videos = [{"action_only_output": True}] if action_only else self._extract_video_outputs(result) audios = self._extract_audio_outputs(result, expected_count=len(videos)) actions = self._extract_action_outputs(result, expected_count=len(videos)) @@ -340,10 +347,12 @@ class OmniOpenAIServingVideo: return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb, artifacts.actions[0] @staticmethod - def _resolve_video_fps_multiplier(result: Any) -> int: - custom_output = OmniOpenAIServingVideo._extract_custom_output(result) - if isinstance(custom_output, dict): - multiplier = custom_output.get("video_fps_multiplier") + def _resolve_video_fps_multiplier(result: object) -> int: + multimodal_output = OmniOpenAIServingVideo._extract_multimodal_output(result) + metadata = multimodal_output.get("metadata") + video_metadata = metadata.get("video") if isinstance(metadata, Mapping) else None + if isinstance(video_metadata, Mapping): + multiplier = video_metadata.get("video_fps_multiplier") if multiplier is not None: return int(multiplier) return 1 @@ -390,7 +399,7 @@ class OmniOpenAIServingVideo: prompt: OmniTextPrompt, gen_params: OmniDiffusionSamplingParams, request_id: str, - ) -> Any: + ) -> object: stage_configs = self._stage_configs or getattr(self._engine_client, "stage_configs", None) if not stage_configs: @@ -433,7 +442,7 @@ class OmniOpenAIServingVideo: return result @staticmethod - def _normalize_video_outputs(videos: Any) -> list[Any]: + def _normalize_video_outputs(videos: DiffusionPayloadValue | None) -> list[DiffusionPayloadValue]: if videos is None: return [] if hasattr(videos, "ndim") and videos.ndim == 5: @@ -443,7 +452,7 @@ class OmniOpenAIServingVideo: return [] first = videos[0] if hasattr(first, "ndim") and first.ndim == 5: - flattened: list[Any] = [] + flattened: list[DiffusionPayloadValue] = [] for item in videos: if hasattr(item, "ndim") and item.ndim == 5: flattened.extend([item[i] for i in range(item.shape[0])]) @@ -459,7 +468,7 @@ class OmniOpenAIServingVideo: return videos return [videos] - def _extract_video_outputs(self, result: Any) -> list[Any]: + def _extract_video_outputs(self, result: object) -> list[DiffusionPayloadValue]: videos = None if hasattr(result, "images") and result.images: videos = result.images @@ -469,10 +478,14 @@ class OmniOpenAIServingVideo: videos = request_output["images"] elif hasattr(request_output, "images") and request_output.images: videos = request_output.images - elif hasattr(request_output, "multimodal_output") and request_output.multimodal_output: - videos = request_output.multimodal_output.get("video") - if videos is None and hasattr(result, "multimodal_output") and result.multimodal_output: - videos = result.multimodal_output.get("video") + else: + request_multimodal_output = getattr(request_output, "multimodal_output", None) + if isinstance(request_multimodal_output, Mapping): + videos = request_multimodal_output.get("video") + if videos is None: + multimodal_output = getattr(result, "multimodal_output", None) + if isinstance(multimodal_output, Mapping): + videos = multimodal_output.get("video") normalized = self._normalize_video_outputs(videos) if not normalized: @@ -483,17 +496,21 @@ class OmniOpenAIServingVideo: return normalized @staticmethod - def _extract_audio_outputs(result: Any, expected_count: int) -> list[Any | None]: + def _extract_audio_outputs(result: object, expected_count: int) -> list[DiffusionPayloadValue | None]: audio = None - if hasattr(result, "multimodal_output") and result.multimodal_output: - audio = result.multimodal_output.get("audio") + multimodal_output = getattr(result, "multimodal_output", None) + if isinstance(multimodal_output, Mapping): + audio = multimodal_output.get("audio") elif hasattr(result, "request_output"): request_output = result.request_output if isinstance(request_output, dict) and request_output.get("multimodal_output"): mm_output = request_output.get("multimodal_output") or {} - audio = mm_output.get("audio") - elif hasattr(request_output, "multimodal_output") and request_output.multimodal_output: - audio = request_output.multimodal_output.get("audio") + if isinstance(mm_output, Mapping): + audio = mm_output.get("audio") + else: + request_multimodal_output = getattr(request_output, "multimodal_output", None) + if isinstance(request_multimodal_output, Mapping): + audio = request_multimodal_output.get("audio") if audio is None: return [None] * expected_count @@ -514,7 +531,7 @@ class OmniOpenAIServingVideo: return [audio] + [None] * max(expected_count - 1, 0) - def _resolve_audio_sample_rate(self, result: Any) -> int: + def _resolve_audio_sample_rate(self, result: object) -> int: result_sample_rate = self._extract_audio_sample_rate_from_result(result) if result_sample_rate is not None: return result_sample_rate @@ -528,38 +545,45 @@ class OmniOpenAIServingVideo: return 24000 @classmethod - def _extract_action_outputs(cls, result: Any, expected_count: int) -> list[VideoAction | None]: - custom_output = cls._extract_custom_output(result) - if not custom_output or "action" not in custom_output: - return [None] * expected_count + def _extract_action_outputs(cls, result: object, expected_count: int) -> list[VideoAction | None]: + multimodal_output = cls._extract_multimodal_output(result) + if "actions" in multimodal_output: + action_payload = multimodal_output["actions"] + metadata = multimodal_output.get("metadata") + action_metadata = metadata.get("actions") if isinstance(metadata, Mapping) else {} + action_metadata = action_metadata if isinstance(action_metadata, Mapping) else {} + action_items = cls._split_action_payload(action_payload, expected_count) + return [ + cls._make_video_action(action_item, action_metadata) if action_item is not None else None + for action_item in action_items + ] - action_payload = custom_output.get("actions", custom_output["action"]) - action_items = cls._split_action_payload(action_payload, expected_count) - return [ - cls._make_video_action(action_item, custom_output) if action_item is not None else None - for action_item in action_items - ] + return [None] * expected_count @staticmethod - def _extract_custom_output(result: Any) -> dict[str, Any]: - custom_output = getattr(result, "custom_output", None) - if isinstance(custom_output, dict): - return custom_output + def _extract_multimodal_output(result: object) -> DiffusionMultimodalOutput: + multimodal_output = getattr(result, "multimodal_output", None) + if isinstance(multimodal_output, Mapping): + return dict(multimodal_output) request_output = getattr(result, "request_output", None) if isinstance(request_output, dict): - custom_output = request_output.get("custom_output") - if custom_output is None: - custom_output = request_output.get("_custom_output") + multimodal_output = request_output.get("multimodal_output") + if multimodal_output is None: + multimodal_output = request_output.get("_multimodal_output") elif request_output is not None: - custom_output = getattr(request_output, "custom_output", None) - if custom_output is None: - custom_output = getattr(request_output, "_custom_output", None) + multimodal_output = getattr(request_output, "multimodal_output", None) + if multimodal_output is None: + multimodal_output = getattr(request_output, "_multimodal_output", None) - return custom_output if isinstance(custom_output, dict) else {} + return dict(multimodal_output) if isinstance(multimodal_output, Mapping) else {} @classmethod - def _split_action_payload(cls, action: Any, expected_count: int) -> list[Any | None]: + def _split_action_payload( + cls, + action: DiffusionPayloadValue, + expected_count: int, + ) -> list[DiffusionPayloadValue | None]: if expected_count <= 0: return [] @@ -573,30 +597,34 @@ class OmniOpenAIServingVideo: return [action] + [None] * (expected_count - 1) @classmethod - def _make_video_action(cls, action: Any, custom_output: dict[str, Any]) -> VideoAction: + def _make_video_action( + cls, + action: DiffusionPayloadValue, + action_metadata: DiffusionMetadataMapping, + ) -> VideoAction: data = cls._to_jsonable(action) if not isinstance(data, list): data = [data] - action_mode = custom_output.get("action_mode") + action_mode = action_metadata.get("action_mode") return VideoAction( data=data, shape=cls._shape_of(action), dtype=cls._dtype_of(action), - raw_action_dim=cls._coerce_optional_int(custom_output.get("raw_action_dim")), + raw_action_dim=cls._coerce_optional_int(action_metadata.get("raw_action_dim")), action_mode=str(action_mode) if action_mode is not None else None, - domain_id=cls._coerce_optional_int(custom_output.get("domain_id")), + domain_id=cls._coerce_optional_int(action_metadata.get("domain_id")), ) @staticmethod - def _index_action(action: Any, index: int) -> Any: + def _index_action(action: DiffusionPayloadValue, index: int) -> DiffusionPayloadValue | None: try: return action[index] except (IndexError, KeyError, TypeError): return None @classmethod - def _to_jsonable(cls, value: Any) -> Any: + def _to_jsonable(cls, value: DiffusionPayloadValue) -> DiffusionPayloadValue: if hasattr(value, "detach"): value = value.detach() if hasattr(value, "cpu"): @@ -613,7 +641,7 @@ class OmniOpenAIServingVideo: return value @classmethod - def _shape_of(cls, value: Any) -> list[int]: + def _shape_of(cls, value: DiffusionPayloadValue) -> list[int]: shape = getattr(value, "shape", None) if shape is not None: try: @@ -627,12 +655,12 @@ class OmniOpenAIServingVideo: return [] @staticmethod - def _dtype_of(value: Any) -> str | None: + def _dtype_of(value: DiffusionPayloadValue) -> str | None: dtype = getattr(value, "dtype", None) return str(dtype) if dtype is not None else None @staticmethod - def _coerce_optional_int(value: Any) -> int | None: + def _coerce_optional_int(value: object) -> int | None: if value is None: return None try: @@ -642,7 +670,7 @@ class OmniOpenAIServingVideo: return None @staticmethod - def _resolve_fps(result: Any) -> int | None: + def _resolve_fps(result: object) -> int | None: """Extract fps from multimodal_output if the model reported it.""" multimodal_output = getattr(result, "multimodal_output", None) if isinstance(multimodal_output, Mapping): @@ -682,7 +710,7 @@ class OmniOpenAIServingVideo: return None @classmethod - def _extract_audio_sample_rate_from_result(cls, result: Any) -> int | None: + def _extract_audio_sample_rate_from_result(cls, result: object) -> int | None: multimodal_output = getattr(result, "multimodal_output", None) if isinstance(multimodal_output, Mapping): sample_rate = cls._coerce_audio_sample_rate( @@ -717,7 +745,7 @@ class OmniOpenAIServingVideo: return None @classmethod - def _extract_audio_sample_rate_from_config(cls, config: Any) -> int | None: + def _extract_audio_sample_rate_from_config(cls, config: object) -> int | None: if config is None: return None @@ -748,7 +776,7 @@ class OmniOpenAIServingVideo: return None @staticmethod - def _coerce_audio_sample_rate(value: Any) -> int | None: + def _coerce_audio_sample_rate(value: object) -> int | None: if value is None: return None @@ -761,12 +789,12 @@ class OmniOpenAIServingVideo: return sample_rate if sample_rate > 0 else None @staticmethod - def _extract_stage_durations(result: Any) -> dict[str, float]: + def _extract_stage_durations(result: object) -> dict[str, float]: stage_durations = getattr(result, "stage_durations", None) return stage_durations if isinstance(stage_durations, dict) else {} @staticmethod - def _extract_peak_memory_mb(result: Any) -> float: + def _extract_peak_memory_mb(result: object) -> float: peak_memory_mb = getattr(result, "peak_memory_mb", 0.0) try: return float(peak_memory_mb or 0.0) diff --git a/vllm_omni/model_extras/audiox.py b/vllm_omni/model_extras/audiox.py index 2ab7bc4d8..220972148 100644 --- a/vllm_omni/model_extras/audiox.py +++ b/vllm_omni/model_extras/audiox.py @@ -19,7 +19,7 @@ AUDIOX_EXTRA_BODY_PARAMS = frozenset( "audio_path", # optional reference-audio conditioning } ) -# Echoed back in ``DiffusionOutput.custom_output`` (pipeline_audiox.py). +# Echoed back in diffusion output metadata (pipeline_audiox.py). AUDIOX_EXTRA_OUTPUT_PARAMS = frozenset( { "audiox_task", diff --git a/vllm_omni/outputs.py b/vllm_omni/outputs/__init__.py similarity index 100% rename from vllm_omni/outputs.py rename to vllm_omni/outputs/__init__.py diff --git a/vllm_omni/outputs/output_metadata.py b/vllm_omni/outputs/output_metadata.py new file mode 100644 index 000000000..f3e364a34 --- /dev/null +++ b/vllm_omni/outputs/output_metadata.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Literal, TypeAlias, TypedDict + +import numpy as np +import PIL.Image +import torch + +# Final user-facing output modality returned by pipeline stages. +FinalOutputModalityType: TypeAlias = Literal["text", "image", "audio", "video"] + +# Scalar values that may appear in either payload or metadata. +# Examples: text_output="done", fps=16, action_only_output=True. +DiffusionScalar: TypeAlias = str | int | float | bool | None + +# Shape-like metadata. Examples: [1, 16, 3, 480, 832], (16, 480, 832). +DiffusionShape: TypeAlias = list[int] | tuple[int, ...] + +# Named tensor controls used by transfer-style metadata. +# Example: {"pose": pose_tensor, "depth": depth_tensor}. +DiffusionTensorMap: TypeAlias = Mapping[str, torch.Tensor] + +# Values allowed under metadata groups such as video/audio/actions/transfer. +# Examples: {"fps": 16}, {"shape": [1, 16, 3, 480, 832]}, +# {"controls": {"pose": pose_tensor}}, {"hints": ["pose"]}. +DiffusionMetadataValue: TypeAlias = ( + DiffusionScalar + | DiffusionShape + | DiffusionTensorMap + | torch.Tensor + | list["DiffusionMetadataValue"] + | tuple["DiffusionMetadataValue", ...] + | Mapping[str, "DiffusionMetadataValue"] +) + +# Mutable metadata envelope side. Example: +# {"video": {"fps": 16}, "actions": {"raw_action_dim": 14}}. +DiffusionMetadata: TypeAlias = dict[str, DiffusionMetadataValue] + +# Read-only metadata view for validators and serving readers. +DiffusionMetadataMapping: TypeAlias = Mapping[str, DiffusionMetadataValue] + + +class DiffusionTrajectoryPayload(TypedDict, total=False): + # Optional denoising trajectory payload for RL/debug consumers. + latents: torch.Tensor | dict[str, Any] | None + timesteps: torch.Tensor | dict[str, Any] | None + log_probs: torch.Tensor | dict[str, Any] | None + decoded: list[PIL.Image.Image] | None + + +# Concrete payload values produced by post_process. +# Examples: PIL image, video tensor, audio bytes/tensor, action tensor. +DiffusionPayloadValue: TypeAlias = ( + DiffusionScalar + | bytes + | torch.Tensor + | np.ndarray + | PIL.Image.Image + | DiffusionTrajectoryPayload + | list["DiffusionPayloadValue"] + | tuple["DiffusionPayloadValue", ...] + | Mapping[str, "DiffusionPayloadValue"] +) + +# Mutable payload side. Example: {"video": video_tensor, "action": action_tensor}. +DiffusionPayload: TypeAlias = dict[str, DiffusionPayloadValue] + +# Read-only payload view for code that consumes but does not mutate payload. +DiffusionPayloadMapping: TypeAlias = Mapping[str, DiffusionPayloadValue] + +# Serving-facing multimodal output. Non-metadata keys hold payload values. +# Example: {"video": video_tensor, "metadata": {"video": {"fps": 16}}}. +DiffusionMultimodalOutput: TypeAlias = dict[str, DiffusionPayloadValue | DiffusionMetadata] + + +class DiffusionOutputEnvelope(TypedDict, total=False): + # Canonical post_process return shape: + # {"payload": {"video": video_tensor}, "metadata": {"video": {"fps": 16}}} + payload: DiffusionPayload + metadata: DiffusionMetadata + + +# Raw post_process return before formatter normalization. This keeps backward +# compatibility with direct payload returns while accepting the new envelope. +# Examples: image, {"video": video_tensor}, or +# {"payload": {"video": video_tensor}, "metadata": {"video": {"fps": 16}}}. +DiffusionPostprocessRawOutput: TypeAlias = DiffusionPayloadValue | DiffusionPayload | DiffusionOutputEnvelope + + +def strip_internal_metadata(metadata: DiffusionMetadataMapping) -> DiffusionMetadata: + """Return public metadata, dropping model-private postprocess state.""" + return {key: value for key, value in metadata.items() if key != "internal"} + + +def validate_public_diffusion_metadata(metadata: DiffusionMetadataMapping) -> None: + """Validate metadata that is safe to expose outside model postprocess.""" + if "internal" in metadata: + raise ValueError("Diffusion metadata field 'internal' must not escape public formatting.") + validate_diffusion_metadata(metadata) + + +def validate_diffusion_metadata(metadata: DiffusionMetadataMapping) -> None: + """Validate canonical diffusion output metadata fields. + + Unknown model-specific groups and keys are intentionally tolerated. This + validator only guards fields that are part of the shared output protocol or + consumed by serving code. + """ + _validate_video_metadata(_optional_mapping(metadata, "video")) + _validate_audio_metadata(_optional_mapping(metadata, "audio")) + _validate_actions_metadata(_optional_mapping(metadata, "actions")) + _validate_transfer_metadata(_optional_mapping(metadata, "transfer")) + _validate_text_metadata(_optional_mapping(metadata, "text")) + _validate_common_metadata(_optional_mapping(metadata, "common")) + + +def _validate_video_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + _check_optional_positive_number(metadata, "fps", group="video") + _check_optional_int(metadata, "video_fps_multiplier", group="video", min_value=1) + _check_optional_shape(metadata, "shape", group="video") + + +def _validate_audio_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + _check_optional_int(metadata, "sample_rate", group="audio", min_value=1) + _check_optional_non_empty_str(metadata, "audiox_task", group="audio") + + +def _validate_actions_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + _check_optional_int(metadata, "raw_action_dim", group="actions", min_value=1) + _check_optional_non_empty_str(metadata, "action_mode", group="actions") + _check_optional_int(metadata, "domain_id", group="actions") + + +def _validate_transfer_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + controls = metadata.get("controls") + if controls is not None and not _is_tensor_mapping(controls): + raise TypeError("Diffusion metadata field 'transfer.controls' must be a mapping of str to torch.Tensor.") + hints = metadata.get("hints") + if hints is not None and not _is_str_list(hints): + raise TypeError("Diffusion metadata field 'transfer.hints' must be a list of strings.") + + +def _validate_text_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + _check_optional_str(metadata, "text_output", group="text") + _check_optional_str(metadata, "think_text", group="text") + + +def _validate_common_metadata(metadata: DiffusionMetadataMapping | None) -> None: + if metadata is None: + return + value = metadata.get("action_only_output") + if value is not None and not isinstance(value, bool): + raise TypeError("Diffusion metadata field 'common.action_only_output' must be bool.") + + +def _optional_mapping(metadata: DiffusionMetadataMapping, group: str) -> DiffusionMetadataMapping | None: + value = metadata.get(group) + if value is None: + return None + if not isinstance(value, Mapping): + raise TypeError(f"Diffusion metadata group '{group}' must be a mapping.") + return value + + +def _check_optional_positive_number(metadata: DiffusionMetadataMapping, key: str, *, group: str) -> None: + value = metadata.get(key) + if value is None: + return + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"Diffusion metadata field '{group}.{key}' must be a positive number.") + if value <= 0: + raise ValueError(f"Diffusion metadata field '{group}.{key}' must be > 0.") + + +def _check_optional_int( + metadata: DiffusionMetadataMapping, + key: str, + *, + group: str, + min_value: int | None = None, +) -> None: + value = metadata.get(key) + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"Diffusion metadata field '{group}.{key}' must be int.") + if min_value is not None and value < min_value: + raise ValueError(f"Diffusion metadata field '{group}.{key}' must be >= {min_value}.") + + +def _check_optional_str(metadata: DiffusionMetadataMapping, key: str, *, group: str) -> None: + value = metadata.get(key) + if value is not None and not isinstance(value, str): + raise TypeError(f"Diffusion metadata field '{group}.{key}' must be str.") + + +def _check_optional_non_empty_str(metadata: DiffusionMetadataMapping, key: str, *, group: str) -> None: + value = metadata.get(key) + if value is None: + return + if not isinstance(value, str): + raise TypeError(f"Diffusion metadata field '{group}.{key}' must be str.") + if not value: + raise ValueError(f"Diffusion metadata field '{group}.{key}' must be non-empty.") + + +def _check_optional_shape(metadata: DiffusionMetadataMapping, key: str, *, group: str) -> None: + value = metadata.get(key) + if value is None: + return + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise TypeError(f"Diffusion metadata field '{group}.{key}' must be a list or tuple of positive ints.") + if any(isinstance(dim, bool) or not isinstance(dim, int) or dim <= 0 for dim in value): + raise ValueError(f"Diffusion metadata field '{group}.{key}' must contain positive ints.") + + +def _is_tensor_mapping(value: object) -> bool: + return isinstance(value, Mapping) and all( + isinstance(key, str) and isinstance(tensor, torch.Tensor) for key, tensor in value.items() + ) + + +def _is_str_list(value: object) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) From b14ff23041cc88b393260d4ee7100671e7871176 Mon Sep 17 00:00:00 2001 From: bjf-frz <frz123db@gmail.com> Date: Tue, 14 Jul 2026 18:25:17 +0800 Subject: [PATCH 084/101] [PERF][CI]Add Cosmos3 diffusion perf config (#5010) Signed-off-by: bjf-frz <frz123db@gmail.com> --- .buildkite/test-nightly.yml | 5 +- benchmarks/diffusion/backends.py | 17 +- .../diffusion/diffusion_benchmark_serving.py | 116 +++++++++- .../perf/scripts/run_diffusion_benchmark.py | 2 +- .../perf/tests/test_cosmos3_vllm_omni.json | 213 ++++++++++++++++++ 5 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 tests/dfx/perf/tests/test_cosmos3_vllm_omni.json diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 9b1206568..783d0ab1f 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -1489,9 +1489,12 @@ steps: set +e pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json EXIT1=$$? + pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json + EXIT2=$$? buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json" buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log" - exit $$EXIT1 + if [ $$EXIT1 -ne 0 ]; then exit $$EXIT1; fi + exit $$EXIT2 agents: queue: "mithril-h100-pool" plugins: diff --git a/benchmarks/diffusion/backends.py b/benchmarks/diffusion/backends.py index 368dc5dcb..f8ad1a71e 100644 --- a/benchmarks/diffusion/backends.py +++ b/benchmarks/diffusion/backends.py @@ -28,6 +28,7 @@ class RequestFuncInput: slo_ms: float | None = None extra_body: dict[str, Any] = field(default_factory=dict) image_paths: list[str] | None = None + video_paths: list[str] | None = None request_id: str = field(default_factory=lambda: str(uuid.uuid4())) default_bot_task: str | None = DEFAULT_EDITS_BOT_TASK @@ -338,6 +339,11 @@ async def async_request_v1_videos( form.add_field(k, str(v)) image_file = None + if input.image_paths and input.video_paths: + output.error = "Only one of image_paths or video_paths can be provided" + output.success = False + return output + if input.image_paths and len(input.image_paths) > 0: image_path = input.image_paths[0] image_file = open(image_path, "rb") @@ -345,7 +351,16 @@ async def async_request_v1_videos( "input_reference", image_file, filename=os.path.basename(image_path), - content_type="application/octet-stream", + content_type=_guess_mime_type(image_path), + ) + elif input.video_paths and len(input.video_paths) > 0: + video_path = input.video_paths[0] + image_file = open(video_path, "rb") + form.add_field( + "input_reference", + image_file, + filename=os.path.basename(video_path), + content_type=_guess_mime_type(video_path), ) job_id = None diff --git a/benchmarks/diffusion/diffusion_benchmark_serving.py b/benchmarks/diffusion/diffusion_benchmark_serving.py index 7e0bd4ed1..8f7a02858 100644 --- a/benchmarks/diffusion/diffusion_benchmark_serving.py +++ b/benchmarks/diffusion/diffusion_benchmark_serving.py @@ -73,6 +73,10 @@ Usage: {"width":854,"height":480,"num_inference_steps":18,"num_frames":120,"fps":24,"weight":1} ]' + v2v: + python3 benchmarks/diffusion/diffusion_benchmark_serving.py \ + --endpoint /v1/videos --dataset random --task v2v --num-prompts 1 \ + --height 720 --width 1280 --fps 24 --num-frames 189 --num-inference-steps 35 """ @@ -542,11 +546,17 @@ class TraceDataset(BaseDataset): if not image_paths: single = row.get("image_path") image_paths = [single] if single else None + video_paths = row.get("video_paths") + if not video_paths: + single_video = row.get("video_path") + video_paths = [single_video] if single_video else None if not image_paths and self.args.task in ["i2v", "i2i", "ti2v", "ti2i", "it2i"]: raise ValueError( f"Task {self.args.task} requires image input, but no image_path or image_paths found in trace row." ) + if not video_paths and self.args.task == "v2v": + raise ValueError("Task v2v requires video input, but no video_path or video_paths found in trace row.") override_w = self.args.width override_h = self.args.height @@ -570,6 +580,7 @@ class TraceDataset(BaseDataset): timestamp=timestamp, slo_ms=slo_ms, image_paths=image_paths, + video_paths=video_paths, request_id=str(row.get("request_id")) if row.get("request_id") is not None else str(uuid.uuid4()), ) @@ -589,6 +600,8 @@ class CustomDataset(BaseDataset): - seed (optional): Random seed - image_paths (optional): List of input image paths for i2i/i2v/ti2i tasks - image_urls (optional): List of input image URLs (alternative to image_paths) + - video_paths (optional): List of input video paths for v2v tasks + - video_urls (optional): List of input video URLs (alternative to video_paths) Example JSONL for ti2i: {"prompt": "Add sunset lighting", "width": 1024, "height": 1024, "image_urls": ["https://example.com/image.jpg"]} @@ -660,6 +673,42 @@ class CustomDataset(BaseDataset): return None + def _resolve_video_paths(self, item: dict) -> list[str] | None: + if "video_paths" in item and item["video_paths"]: + paths = item["video_paths"] + if isinstance(paths, str): + paths = [paths] + + valid_paths = [] + for path in paths: + if os.path.exists(path): + valid_paths.append(path) + else: + raise ValueError(f"Video file not found: {path}") + + return valid_paths if valid_paths else None + + if "video_urls" in item and item["video_urls"]: + urls = item["video_urls"] + if isinstance(urls, str): + urls = [urls] + + downloaded_paths = [] + for url in urls: + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + suffix = os.path.splitext(url)[1] or ".mp4" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(response.content) + downloaded_paths.append(tmp.name) + except Exception as e: + raise ValueError(f"Failed to download video from {url}: {e}") + + return downloaded_paths if downloaded_paths else None + + return None + def __len__(self) -> int: return len(self.data) @@ -672,11 +721,22 @@ class CustomDataset(BaseDataset): height = item.get("height", self.args.height) num_inference_steps = item.get("num_inference_steps", self.args.num_inference_steps) seed = item.get("seed", self.args.seed) - reserved_keys = {"prompt", "width", "height", "num_inference_steps", "seed", "image_paths", "image_urls"} + reserved_keys = { + "prompt", + "width", + "height", + "num_inference_steps", + "seed", + "image_paths", + "image_urls", + "video_paths", + "video_urls", + } extra_body = {k: v for k, v in item.items() if k not in reserved_keys} # Handle image paths/URLs image_paths = self._resolve_image_paths(item) + video_paths = self._resolve_video_paths(item) return RequestFuncInput( prompt=prompt, @@ -684,6 +744,7 @@ class CustomDataset(BaseDataset): model=self.model, seed=seed, image_paths=image_paths, + video_paths=video_paths, extra_body=extra_body, width=width, height=height, @@ -737,6 +798,10 @@ class RandomDataset(BaseDataset): self._random_image_path = self._generate_random_image_paths() else: self._random_image_path = None + if self.args.task == "v2v": + self._random_video_path = self._generate_random_video_paths() + else: + self._random_video_path = None def __len__(self) -> int: return self.num_prompts @@ -756,13 +821,15 @@ class RandomDataset(BaseDataset): if self._sampled_requests: profile = self._sampled_requests[idx] params.update(profile) + prompt = str(params.pop("prompt", f"Random prompt {idx} for benchmarking diffusion models")) return RequestFuncInput( - prompt=f"Random prompt {idx} for benchmarking diffusion models", + prompt=prompt, api_url=self.api_url, model=self.model, seed=self.args.seed, extra_body=extra_body, image_paths=self._random_image_path, + video_paths=self._random_video_path, **params, ) @@ -781,6 +848,35 @@ class RandomDataset(BaseDataset): image_paths.append(image_path) return image_paths + def _generate_random_video_paths(self) -> list[str]: + video_path = os.path.join(tempfile.gettempdir(), "diffusion_benchmark_random_video.mp4") + try: + import cv2 + + profile = self.random_request_config[0] if self.random_request_config else {} + width = int(self.args.width or profile.get("width") or 1280) + height = int(self.args.height or profile.get("height") or 720) + num_frames = int(self.args.num_frames or profile.get("num_frames") or 16) + fps = float(self.args.fps or profile.get("fps") or 8) + writer = cv2.VideoWriter(video_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) + if not writer.isOpened(): + raise RuntimeError("cv2.VideoWriter failed to open") + for frame_idx in range(num_frames): + frame = np.zeros((height, width, 3), dtype=np.uint8) + x = int((frame_idx / max(num_frames - 1, 1)) * max(width - 96, 1)) + y = height // 2 + frame[:, :, 0] = 24 + frame[:, :, 1] = 32 + frame[:, :, 2] = 48 + frame[max(y - 36, 0) : min(y + 36, height), x : min(x + 96, width), :] = (64, 128, 220) + writer.write(frame) + writer.release() + return [video_path] + except Exception as e: + raise RuntimeError( + "Failed to generate synthetic v2v input video. Install opencv-python or provide a custom dataset." + ) from e + def _compute_expected_latency_ms_from_base(req: RequestFuncInput, args, base_time_ms: float | None) -> float | None: """Compute expected execution time (ms) based on a base per-step-per-frame unit time. @@ -1112,7 +1208,7 @@ def wait_for_service(base_url: str, timeout: int = 120) -> None: def _default_endpoint_for_task(task: str) -> str: - if task in {"t2v", "i2v", "ti2v"}: + if task in {"t2v", "i2v", "ti2v", "v2v"}: return "/v1/videos" if task in {"i2i", "ti2i", "it2i"}: return "/v1/images/edits" @@ -1126,7 +1222,7 @@ async def benchmark(args): if args.base_url is None: args.base_url = f"http://{args.host}:{args.port}" - VIDEO_TASKS = {"t2v", "i2v", "ti2v"} + VIDEO_TASKS = {"t2v", "i2v", "ti2v", "v2v"} IMAGE_TASKS = {"t2i", "i2i", "ti2i", "it2i"} if args.task in VIDEO_TASKS: @@ -1177,6 +1273,10 @@ async def benchmark(args): if args.return_stage_metrics and args.endpoint in _STAGE_METRICS_ENDPOINTS: for req in requests_list: req.extra_body.setdefault(_RETURN_STAGE_METRICS_FIELD, True) + if args.extra_body: + extra_body = json.loads(args.extra_body) + for req in requests_list: + req.extra_body.update(extra_body) if args.endpoint == "/v1/images/edits": for req in requests_list: @@ -1328,7 +1428,7 @@ if __name__ == "__main__": "--task", type=str, default="t2v", - choices=["t2v", "i2v", "ti2v", "ti2i", "i2i", "it2i", "t2i"], + choices=["t2v", "i2v", "ti2v", "v2v", "ti2i", "i2i", "it2i", "t2i"], help="Task type.", ) parser.add_argument( @@ -1443,6 +1543,12 @@ if __name__ == "__main__": '{"width":768,"height":768,"num_inference_steps":20,"weight":0.85}]' ), ) + parser.add_argument( + "--extra-body", + type=str, + default=None, + help="JSON object merged into every generated request body/form.", + ) parser.add_argument( "--num-input-images", type=int, diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py index 98462743d..4c185066d 100644 --- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py +++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py @@ -807,7 +807,7 @@ def assert_result( def _default_benchmark_endpoint_for_task(task: str) -> str: """Return the default client-side benchmark endpoint for a diffusion task.""" - if task in {"t2v", "i2v", "ti2v"}: + if task in {"t2v", "i2v", "ti2v", "v2v"}: return "/v1/videos" if task in {"t2i", "i2i", "ti2i"}: return "/v1/chat/completions" diff --git a/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json new file mode 100644 index 000000000..b0fc9a5a4 --- /dev/null +++ b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json @@ -0,0 +1,213 @@ +[ + { + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "description": "Cosmos3-Nano text-to-image official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/generations", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps4", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 4, + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "extra-body": { + "guidance_scale": 7.0, + "flow_shift": 3.0, + "guardrails": false + }, + "baseline": { + "throughput_qps": 1.107, + "latency_mean": 0.8929, + "peak_memory_mb_mean": 80000.0 + } + } + ] + }, + { + "test_name": "test_cosmos3_t2v_official_demo_2gpu", + "description": "Cosmos3-Nano text-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "t2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "A robot arm is cleaning a plate in the kitchen.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "use_resolution_template": false, + "use_duration_template": false + }, + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 36.6824, + "peak_memory_mb_mean": 30155.4 + } + } + ] + }, + { + "test_name": "test_cosmos3_i2v_official_demo_2gpu", + "description": "Cosmos3-Nano image-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 3, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "The scene comes to life with smooth, natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false + }, + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 36.968, + "peak_memory_mb_mean": 30443.6 + } + } + ] + }, + { + "test_name": "test_cosmos3_v2v_official_demo_2gpu", + "description": "Cosmos3-Nano video-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "v2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "Continue the same scene with smooth natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "condition_frame_indexes_vision": [ + 0, + 1 + ], + "condition_video_keep": "first" + }, + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 37.2117, + "peak_memory_mb_mean": 30135.6 + } + } + ] + } +] From 6603450d71619d0731ca26ba8a077a3ac491bd77 Mon Sep 17 00:00:00 2001 From: wuli666 <421774554@qq.com> Date: Tue, 14 Jul 2026 20:06:55 +0800 Subject: [PATCH 085/101] [Bugfix][Quantization] Initialize component quantization base state (#5103) Signed-off-by: wuli666 <djjpro975@gmail.com> Co-authored-by: wuli666 <djjpro975@gmail.com> --- vllm_omni/quantization/component_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/vllm_omni/quantization/component_config.py b/vllm_omni/quantization/component_config.py index 87564ef46..28c33d637 100644 --- a/vllm_omni/quantization/component_config.py +++ b/vllm_omni/quantization/component_config.py @@ -80,6 +80,7 @@ class ComponentQuantizationConfig(QuantizationConfig): component_configs: dict[str, QuantizationConfig | None], default_config: QuantizationConfig | None = None, ) -> None: + super().__init__() self._components = component_configs self._default = default_config self._sorted_prefixes = sorted(self._components.keys(), key=len, reverse=True) From a7b6a174bc215fd7b79121740f82c59482e5b0ac Mon Sep 17 00:00:00 2001 From: wkutak <wkutak@nvidia.com> Date: Wed, 15 Jul 2026 02:33:27 +0200 Subject: [PATCH 086/101] Fix loading of ModelOpt FP8 checkpoints for Cosmos3 (#5076) Signed-off-by: Wojciech Kutak <wkutak@nvidia.com> --- .../model_loader/test_modelopt_fp8_adapter.py | 106 ++++++++++++++++++ .../checkpoint_adapters/modelopt.py | 34 ++++-- .../models/cosmos3/pipeline_cosmos3.py | 7 ++ 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py index 513e80d28..66fa87a3c 100644 --- a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py +++ b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py @@ -42,6 +42,53 @@ class _QuantizedPackedModelOptModel(nn.Module): ) +class _RemappedModelOptModel(nn.Module): + def __init__(self) -> None: + super().__init__() + self.runtime = nn.Module() + self.runtime.proj = nn.Linear(2, 2, bias=False) + + @staticmethod + def remap_checkpoint_key(name: str) -> str: + return { + "transformer.orig.proj.weight": "runtime.proj.weight", + }.get(name, name) + + +class _QuantizedRemappedModelOptModel(nn.Module): + """FP8 target whose params live under a namespace the raw checkpoint keys + never use, reachable only via ``remap_checkpoint_key``. + + Mirrors the Cosmos3 case that regressed: the generic weights-mapper cannot + resolve ``transformer.orig.*`` to ``runtime.*``, so without the remap hook + the adapter treats every tensor as unresolved and silently drops the scales. + """ + + def __init__(self) -> None: + super().__init__() + self.runtime = nn.Module() + self.runtime.proj = nn.Module() + self.runtime.proj.register_parameter( + "weight", + nn.Parameter(torch.empty(2, 2, dtype=torch.float8_e4m3fn), requires_grad=False), + ) + self.runtime.proj.register_parameter( + "weight_scale", + nn.Parameter(torch.empty(1), requires_grad=False), + ) + self.runtime.proj.register_parameter( + "input_scale", + nn.Parameter(torch.empty(1), requires_grad=False), + ) + + @staticmethod + def remap_checkpoint_key(name: str) -> str: + prefix = "transformer.orig.proj." + if name.startswith(prefix): + return "runtime.proj." + name[len(prefix) :] + return name + + def _make_source() -> SimpleNamespace: return SimpleNamespace( subfolder="transformer", @@ -92,3 +139,62 @@ def test_modelopt_adapter_keeps_scale_tensors_for_quantized_target(): "transformer.block.to_q.weight_scale", "transformer.block.to_q.input_scale", ] + + +def test_modelopt_adapter_uses_checkpoint_key_remap_for_target_dtype(): + model = _RemappedModelOptModel() + adapter = ModelOptFp8CheckpointAdapter(model, _make_source()) + fp8_weight = torch.tensor([[2.0, -4.0], [1.0, 3.0]], dtype=torch.float32).to(torch.float8_e4m3fn) + scale = torch.tensor([0.5], dtype=torch.float32) + + adapted = list( + adapter.adapt( + iter( + [ + ("transformer.orig.proj.weight", fp8_weight), + ("transformer.orig.proj.weight_scale", scale), + ] + ) + ) + ) + + assert [name for name, _ in adapted] == ["runtime.proj.weight"] + assert adapted[0][1].dtype == model.runtime.proj.weight.dtype + assert torch.allclose(adapted[0][1], fp8_weight.to(torch.float32) * scale) + + +def test_modelopt_adapter_remaps_and_keeps_scales_for_quantized_target(): + """Regression: scales were dropped for a quantized (FP8) target that only the + model's ``remap_checkpoint_key`` hook can resolve. Assert both scales are + emitted and the FP8 weight passes through unchanged (no dequantization).""" + model = _QuantizedRemappedModelOptModel() + adapter = ModelOptFp8CheckpointAdapter(model, _make_source()) + fp8_weight = torch.tensor([[2.0, -4.0], [1.0, 3.0]], dtype=torch.float32).to(torch.float8_e4m3fn) + weight_scale = torch.tensor([0.5], dtype=torch.float32) + input_scale = torch.tensor([1.0], dtype=torch.float32) + + adapted = list( + adapter.adapt( + iter( + [ + ("transformer.orig.proj.weight_scale", weight_scale), + ("transformer.orig.proj.input_scale", input_scale), + ("transformer.orig.proj.weight", fp8_weight), + ] + ) + ) + ) + + emitted = dict(adapted) + assert len(adapted) == len(emitted) == 3 + + # Both scales are retained (the dropped-scale regression). They keep their + # source-checkpoint names; the model's load_weights remaps them downstream. + assert torch.equal(emitted["transformer.orig.proj.weight_scale"], weight_scale) + assert torch.equal(emitted["transformer.orig.proj.input_scale"], input_scale) + + # The FP8 weight is passed through unchanged under the remapped target name + # (the quantized param is FP8, so it must NOT be dequantized). Compare via + # float32 since torch.equal has no FP8 CPU kernel. + assert emitted["runtime.proj.weight"].dtype == torch.float8_e4m3fn + assert torch.equal(emitted["runtime.proj.weight"].to(torch.float32), fp8_weight.to(torch.float32)) diff --git a/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py b/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py index 04fc8ea32..d6f7b3e0c 100644 --- a/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py +++ b/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py @@ -32,7 +32,7 @@ FP8_DTYPES = tuple( @dataclass class _AdaptState: scale_tensors: dict[str, torch.Tensor] = field(default_factory=dict) - pending_weights: dict[str, list[tuple[str, torch.Tensor, torch.dtype]]] = field(default_factory=dict) + pending_weights: dict[str, list[tuple[str, str, torch.Tensor, torch.dtype]]] = field(default_factory=dict) skipped_scales: int = 0 dequantized_weights: int = 0 @@ -41,6 +41,7 @@ class ModelOptFp8CheckpointAdapter: def __init__(self, model: nn.Module, source: object): self._loadable_tensors = self._get_model_loadable_tensors(model) self._weights_mapper = self._get_weights_mapper(model) + self._checkpoint_key_mapper = getattr(model, "remap_checkpoint_key", None) self._source_label = getattr(source, "prefix", "") or getattr(source, "subfolder", None) or "model" @classmethod @@ -110,14 +111,19 @@ class ModelOptFp8CheckpointAdapter: orig_to_new_prefix=orig_to_new_prefix, ) - def _resolve_target_name(self, name: str) -> str | None: + def _resolve_target_and_output_names(self, name: str) -> tuple[str | None, str]: if name in self._loadable_tensors: - return name + return name, name + + if callable(self._checkpoint_key_mapper): + candidate = self._checkpoint_key_mapper(name) + if candidate in self._loadable_tensors: + return candidate, candidate for candidate in self._weights_mapper.apply_list([name]): if candidate != name and candidate in self._loadable_tensors: - return candidate - return None + return candidate, name + return None, name @staticmethod def _reshape_weight_scale(scale: torch.Tensor, weight_shape: torch.Size) -> torch.Tensor: @@ -160,8 +166,13 @@ class ModelOptFp8CheckpointAdapter: scale_name: str, state: _AdaptState, ) -> Generator[tuple[str, torch.Tensor], None, None]: - for weight_name, weight_tensor, target_dtype in state.pending_weights.pop(scale_name, []): - yield weight_name, self._dequantize_weight(weight_name, weight_tensor, state, target_dtype) + for ( + weight_name, + output_name, + weight_tensor, + target_dtype, + ) in state.pending_weights.pop(scale_name, []): + yield output_name, self._dequantize_weight(weight_name, weight_tensor, state, target_dtype) state.dequantized_weights += 1 def _handle_scale_tensor( @@ -194,6 +205,7 @@ class ModelOptFp8CheckpointAdapter: def _maybe_dequantize_or_defer_weight( self, name: str, + output_name: str, tensor: torch.Tensor, target_dtype: torch.dtype, state: _AdaptState, @@ -203,7 +215,7 @@ class ModelOptFp8CheckpointAdapter: raise ValueError(f"Missing ModelOpt FP8 weight_scale name for weight {name!r}") if scale_name not in state.scale_tensors: - state.pending_weights.setdefault(scale_name, []).append((name, tensor, target_dtype)) + state.pending_weights.setdefault(scale_name, []).append((name, output_name, tensor, target_dtype)) return None state.dequantized_weights += 1 @@ -235,17 +247,17 @@ class ModelOptFp8CheckpointAdapter: state = _AdaptState() for name, tensor in weights: - target_name = self._resolve_target_name(name) + target_name, output_name = self._resolve_target_and_output_names(name) if self._is_scale(name): yield from self._handle_scale_tensor(name, tensor, target_name, state) continue target_dtype = self._target_dtype_for_dequantization(tensor, target_name) if target_dtype is not None: - tensor = self._maybe_dequantize_or_defer_weight(name, tensor, target_dtype, state) + tensor = self._maybe_dequantize_or_defer_weight(name, output_name, tensor, target_dtype, state) if tensor is None: continue - yield name, tensor + yield output_name, tensor self._check_pending_weights(state) self._log_adaptation_summary(state) diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index cd8c12777..5d0e24789 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -1018,6 +1018,9 @@ class Cosmos3OmniDiffusersPipeline( return None + # Checkpoint adapters use this hook before model-specific weight loading. + remap_checkpoint_key = _remap_ckpt_key + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Stream-remap checkpoint weights and load via AutoWeightsLoader. @@ -1032,6 +1035,10 @@ class Cosmos3OmniDiffusersPipeline( total = kept = 0 for name, tensor in weights: total += 1 + if name in allowed or name in tp_aware: + kept += 1 + yield name, tensor + continue remapped = self._remap_ckpt_key(name) if remapped is not None and (remapped in allowed or remapped in tp_aware): kept += 1 From 2d58070f6024130a907f5291cebc59457d584843 Mon Sep 17 00:00:00 2001 From: zyz111222 <zouyizhou@huawei.com> Date: Wed, 15 Jul 2026 08:57:44 +0800 Subject: [PATCH 087/101] [Hardware][Ascend][Model] Optimize Qwen3-TTS on 310P (#4841) Signed-off-by: zouyizhou <zouyizhou@huawei.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com> --- .../qwen3_tts/test_code_predictor_dtype.py | 40 ++ tests/platforms/npu/test_310p_patches.py | 431 +++++++++++++++--- .../models/common/qwen3_code_predictor.py | 112 +++-- .../platforms/npu/_310p/patch/__init__.py | 13 +- .../platforms/npu/_310p/patch/qwen3_tts.py | 383 +++++++++++++--- vllm_omni/platforms/npu/_310p/patch/worker.py | 5 + .../npu/models/qwen3_tts_code2wav.py | 40 +- .../npu/models/qwen3_tts_tokenizer_v2.py | 47 ++ vllm_omni/platforms/npu/platform.py | 4 + 9 files changed, 909 insertions(+), 166 deletions(-) create mode 100644 vllm_omni/platforms/npu/models/qwen3_tts_tokenizer_v2.py diff --git a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py index 519940ec3..5e31c2cbd 100644 --- a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py +++ b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py @@ -49,6 +49,11 @@ def _load_module(name: str, filename: str): def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]: """Build the dict of modules to inject into sys.modules.""" + + class NativeCustomOp(torch.nn.Module): + def forward(self, *args, **kwargs): + return self.forward_native(*args, **kwargs) + platforms_mock = mocker.MagicMock() platforms_mock.current_omni_platform.supports_torch_inductor.return_value = False platforms_mock.current_omni_platform.is_npu.return_value = False @@ -76,6 +81,8 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]: vllm_parallel_mock = mocker.MagicMock() vllm_parallel_mock.VocabParallelEmbedding = torch.nn.Embedding + custom_op_mock = types.ModuleType("vllm_omni.diffusion.layers.custom_op") + custom_op_mock.CustomOp = NativeCustomOp return { "vllm_omni": mocker.MagicMock(), @@ -85,6 +92,7 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]: "vllm.config.vllm": vllm_config_mod, "vllm.model_executor.model_loader.weight_utils": weight_utils_mock, "vllm.model_executor.layers.vocab_parallel_embedding": vllm_parallel_mock, + "vllm_omni.diffusion.layers.custom_op": custom_op_mock, "vllm_omni.model_executor": types.ModuleType("vllm_omni.model_executor"), "vllm_omni.model_executor.models": models_pkg, "vllm_omni.model_executor.models.common": common_pkg, @@ -168,6 +176,38 @@ def _make_vllm_config(mocker: MockerFixture, max_num_seqs: int = 4): return vllm_config +def test_npu_custom_ops_use_fused_norm_and_cached_rope(mocker: MockerFixture, loaded_target_classes) -> None: + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + cp_config, _ = _make_tiny_config(loaded_target_classes) + rms_calls = [] + + def npu_rms_norm(hidden_states, weight, epsilon): + rms_calls.append((hidden_states, weight, epsilon)) + return hidden_states + 1, None + + mocker.patch.object(common_mod.current_omni_platform, "is_npu", return_value=True) + mocker.patch.object( + common_mod, + "torch_npu", + types.SimpleNamespace(npu_rms_norm=npu_rms_norm), + create=True, + ) + + hidden_states = torch.zeros(1, 2, cp_config.hidden_size, dtype=torch.float16) + norm = common_mod._RMSNorm(cp_config.hidden_size, eps=cp_config.rms_norm_eps) + torch.testing.assert_close(norm.forward_npu(hidden_states), hidden_states + 1) + assert len(rms_calls) == 1 + assert rms_calls[0][2] == cp_config.rms_norm_eps + + rotary = common_mod._RotaryEmbedding(cp_config) + assert rotary.cos_cached.shape == (cp_config.num_code_groups + 1, cp_config.head_dim) + assert rotary.sin_cached.shape == rotary.cos_cached.shape + position_ids = torch.tensor([[0, 2, 4]]) + cos, sin = rotary.forward_npu(hidden_states, position_ids) + torch.testing.assert_close(cos, rotary.cos_cached[position_ids].to(torch.float16)) + torch.testing.assert_close(sin, rotary.sin_cached[position_ids].to(torch.float16)) + + class TestCodePredictorDtypeAlignment: """Test that code predictor buffers match model parameter dtype.""" diff --git a/tests/platforms/npu/test_310p_patches.py b/tests/platforms/npu/test_310p_patches.py index 255b092c8..e138a6d63 100644 --- a/tests/platforms/npu/test_310p_patches.py +++ b/tests/platforms/npu/test_310p_patches.py @@ -67,10 +67,37 @@ def _install_qwen3_tts_patch_fakes(monkeypatch: pytest.MonkeyPatch): class FakeCodePredictorBaseModel(torch.nn.Module): pass - class FakeMimiEuclideanCodebook(torch.nn.Module): - @property - def embed(self): - return self._embed + class FakeProjection(torch.nn.Linear): + def __init__(self): + super().__init__(4, 4, bias=False) + self.call_shapes: list[tuple[int, ...]] = [] + + def forward(self, hidden_states): + self.call_shapes.append(tuple(hidden_states.shape)) + return super().forward(hidden_states) + + class FakeCodePredictorWrapper(torch.nn.Module): + def __init__(self, *args, **kwargs): + del args, kwargs + super().__init__() + self.model = torch.nn.Module() + self.model.codec_embedding = torch.nn.ModuleList([torch.nn.Embedding(8, 4), torch.nn.Embedding(8, 4)]) + self.model.linear = torch.nn.Linear(4, 4, bias=False) + self.lm_head = torch.nn.ModuleList([torch.nn.Linear(4, 8, bias=False), torch.nn.Linear(4, 8, bias=False)]) + self.small_to_mtp_projection = FakeProjection() + with torch.no_grad(): + self.small_to_mtp_projection.weight.copy_(torch.diag(torch.tensor([1.0, 2.0, 3.0, 4.0]))) + self._wrapper_config = SimpleNamespace(use_parallel_embedding=False, sampling_mode="per_call") + self._projected_codec_embed_weight = None + + def load_weights(self, weights): + del weights + return {"loaded"} + + class FakeCode2WavBase(torch.nn.Module): + def __init__(self, *args, **kwargs): + del args, kwargs + super().__init__() class FakeEncoder: def __init__(self): @@ -136,8 +163,21 @@ def _install_qwen3_tts_patch_fakes(monkeypatch: pytest.MonkeyPatch): CodePredictorAttention=FakeCodePredictorAttention, CodePredictorDecoderLayer=FakeCodePredictorDecoderLayer, CodePredictorBaseModel=FakeCodePredictorBaseModel, + CodePredictorWrapper=FakeCodePredictorWrapper, _rotate_half=lambda x: x, ) + fake_qwen3_tts_code_predictor_vllm = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code_predictor_vllm", + Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM=FakeCodePredictorWrapper, + Qwen3TTSTalkerCodePredictorModelVLLM=FakeCodePredictorBaseModel, + CodePredictorWrapper=FakeCodePredictorWrapper, + ) + fake_qwen3_tts_code2wav = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav", + Qwen3TTSCode2Wav=FakeCode2WavBase, + ) fake_prompt_builder = _install_fake_module( monkeypatch, "vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder", @@ -150,22 +190,40 @@ def _install_qwen3_tts_patch_fakes(monkeypatch: pytest.MonkeyPatch): Qwen3TTSTalkerForConditionalGeneration=FakeTalkerBase, Qwen3TTSPromptEmbedsBuilder=FakePromptEmbedsBuilder, ) - fake_modeling_mimi = _install_fake_module( - monkeypatch, - "transformers.models.mimi.modeling_mimi", - MimiEuclideanCodebook=FakeMimiEuclideanCodebook, - ) - fake_mimi = _install_fake_module( - monkeypatch, - "transformers.models.mimi", - modeling_mimi=fake_modeling_mimi, - ) _install_fake_module(monkeypatch, "vllm") _install_fake_module(monkeypatch, "vllm.multimodal") _install_fake_module(monkeypatch, "vllm.multimodal.audio", AudioResampler=FakeAudioResampler) - _install_fake_module(monkeypatch, "transformers") - _install_fake_module(monkeypatch, "transformers.models", mimi=fake_mimi) + _install_fake_module(monkeypatch, "torch_npu", npu_format_cast=lambda weight, _fmt: weight) + _install_fake_module(monkeypatch, "vllm_ascend") + _install_fake_module(monkeypatch, "vllm_ascend._310p") + _install_fake_module(monkeypatch, "vllm_ascend._310p.attention") + _install_fake_module( + monkeypatch, + "vllm_ascend._310p.attention.attention_mask", + AttentionMaskBuilder310=SimpleNamespace( + gen_causal_additive_mask=lambda max_seq, device: torch.zeros( + max_seq, + max_seq, + device=device, + ) + ), + ) + _install_fake_module(monkeypatch, "vllm_ascend.sample") + _install_fake_module( + monkeypatch, + "vllm_ascend.sample.sampler", + apply_top_k_top_p=lambda logits, **_kwargs: logits, + random_sample=lambda probs, _generators: probs.argmax(dim=-1, keepdim=True), + ) + _install_fake_module( + monkeypatch, + "vllm_ascend.utils", + ACL_FORMAT_FRACTAL_NZ=29, + aligned_16=lambda tensor: tensor, + maybe_trans_nz=lambda weight: weight, + nd_to_nz_2d=lambda tensor: tensor, + ) _install_fake_module(monkeypatch, "vllm_omni") _install_fake_module(monkeypatch, "vllm_omni.model_executor") _install_fake_module(monkeypatch, "vllm_omni.model_executor.models") @@ -178,9 +236,17 @@ def _install_qwen3_tts_patch_fakes(monkeypatch: pytest.MonkeyPatch): monkeypatch, "vllm_omni.model_executor.models.qwen3_tts", prompt_embeds_builder=fake_prompt_builder, + qwen3_tts_code2wav=fake_qwen3_tts_code2wav, + qwen3_tts_code_predictor_vllm=fake_qwen3_tts_code_predictor_vllm, qwen3_tts_talker=fake_talker, ) - return fake_qwen3_code_predictor, fake_prompt_builder, fake_talker + return ( + fake_qwen3_code_predictor, + fake_qwen3_tts_code_predictor_vllm, + fake_qwen3_tts_code2wav, + fake_prompt_builder, + fake_talker, + ) def _load_qwen3_tts_patch(monkeypatch: pytest.MonkeyPatch): @@ -193,7 +259,7 @@ def _load_qwen3_tts_patch(monkeypatch: pytest.MonkeyPatch): def test_registry_applies_worker_once_and_model_patch_lazily(monkeypatch: pytest.MonkeyPatch) -> None: registry_path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "_310p" / "patch" / "__init__.py" registry = _load_source_module("vllm_omni_test_310p_patch_registry", registry_path) - calls = {"worker": 0, "talker": 0} + calls = {"worker": 0, "talker": 0, "code2wav": 0} _install_fake_module( monkeypatch, @@ -204,52 +270,35 @@ def test_registry_applies_worker_once_and_model_patch_lazily(monkeypatch: pytest monkeypatch, "vllm_omni.platforms.npu._310p.patch.qwen3_tts", apply_talker_patches=lambda: calls.__setitem__("talker", calls["talker"] + 1), + apply_code2wav_patches=lambda: calls.__setitem__("code2wav", calls["code2wav"] + 1), ) registry.apply_patches() registry.apply_patches() registry.apply_model_patches(SimpleNamespace(model_arch="OtherModel")) registry.apply_model_patches(SimpleNamespace(model_arch="Qwen3TTSTalkerForConditionalGeneration")) + registry.apply_model_patches(SimpleNamespace(model_arch="Qwen3TTSCode2Wav")) - assert calls == {"worker": 1, "talker": 1} + assert calls == {"worker": 1, "talker": 1, "code2wav": 1} -def test_worker_patch_replaces_base_and_runs_disable_jit(monkeypatch: pytest.MonkeyPatch) -> None: - calls: list[str] = [] +def test_qwen3_tts_patch_registers_target_classes(monkeypatch: pytest.MonkeyPatch) -> None: + ( + module, + ( + fake_code_predictor, + fake_code_predictor_vllm, + fake_code2wav, + fake_prompt_builder, + fake_talker, + ), + ) = _load_qwen3_tts_patch(monkeypatch) - class FakeOmniNPUWorkerBase: - def _init_device(self): - calls.append("parent") - return "npu:0" - - fake_worker_base = _install_fake_module( - monkeypatch, - "vllm_omni.platforms.npu.worker.base", - OmniNPUWorkerBase=FakeOmniNPUWorkerBase, - ) - _install_fake_module(monkeypatch, "vllm_omni") - _install_fake_module(monkeypatch, "vllm_omni.platforms") - _install_fake_module(monkeypatch, "vllm_omni.platforms.npu") - _install_fake_module( - monkeypatch, - "vllm_omni.platforms.npu._310p", - disable_jit_compile=lambda: calls.append("disable_jit"), - ) - _install_fake_module(monkeypatch, "vllm_omni.platforms.npu.worker", base=fake_worker_base) - - path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "_310p" / "patch" / "worker.py" - module = _load_source_module("vllm_omni_test_310p_worker_patch", path) - module.apply_patch() - - assert fake_worker_base.OmniNPUWorkerBase is module._OmniNPUWorkerBase310P - assert fake_worker_base.OmniNPUWorkerBase()._init_device() == "npu:0" - assert calls == ["parent", "disable_jit"] - - -def test_qwen3_tts_patch_replaces_target_classes(monkeypatch: pytest.MonkeyPatch) -> None: - module, (fake_code_predictor, fake_prompt_builder, fake_talker) = _load_qwen3_tts_patch(monkeypatch) + original_common_wrapper = fake_code_predictor.CodePredictorWrapper + original_vllm_wrapper = fake_code_predictor_vllm.CodePredictorWrapper module.apply_talker_patches() + module.apply_code2wav_patches() assert fake_talker.Qwen3TTSTalkerForConditionalGeneration is module._Qwen3TTSTalker310P assert fake_talker.Qwen3TTSPromptEmbedsBuilder is module._Qwen3TTSPromptEmbedsBuilder310P @@ -257,6 +306,112 @@ def test_qwen3_tts_patch_replaces_target_classes(monkeypatch: pytest.MonkeyPatch assert fake_code_predictor.CodePredictorAttention is module._Qwen3CodePredictorAttention310P assert fake_code_predictor.CodePredictorDecoderLayer is module._Qwen3CodePredictorDecoderLayer310P assert fake_code_predictor.CodePredictorBaseModel is module._Qwen3CodePredictorBaseModel310P + assert ( + fake_code_predictor_vllm.Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM + is module._Qwen3TTSTalkerCodePredictor310P + ) + assert fake_code_predictor.CodePredictorWrapper is original_common_wrapper + assert fake_code_predictor_vllm.CodePredictorWrapper is original_vllm_wrapper + assert fake_code2wav.Qwen3TTSCode2Wav is module._Qwen3TTSCode2Wav310P + + code2wav = module._Qwen3TTSCode2Wav310P( + vllm_config=SimpleNamespace(device_config=SimpleNamespace(device=torch.device("cpu"))) + ) + + assert code2wav._npu_decoder_runtime_dtype(torch.device("cpu")) is torch.float16 + + +def test_qwen3_tts_code_predictor_forward_uses_projected_embedding_and_sampling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module, _ = _load_qwen3_tts_patch(monkeypatch) + predictor = module._Qwen3TTSTalkerCodePredictor310P( + vllm_config=object(), + config=object(), + talker_config=object(), + ) + codec_weight = predictor.model.codec_embedding[0].weight.detach().clone() + projection_weight = predictor.small_to_mtp_projection.weight.detach().clone() + + assert predictor.load_weights(iter(())) == {"loaded"} + torch.testing.assert_close( + predictor._projected_codec_embed_weight[0], + torch.nn.functional.linear(codec_weight, projection_weight), + ) + assert predictor.small_to_mtp_projection.call_shapes == [(8, 4), (8, 4)] + predictor.small_to_mtp_projection.call_shapes.clear() + predictor._num_groups = 3 + predictor._model_dtype = torch.float32 + predictor._prefix_graphs_enabled = False + predictor._bucket_pos_ids = {} + predictor._device_graphs = {} + predictor._lm_heads_list = [ + lambda hidden: torch.nn.functional.one_hot( + torch.full((hidden.shape[0],), 2, dtype=torch.long), + num_classes=8, + ).to(torch.float32), + lambda hidden: torch.nn.functional.one_hot( + torch.full((hidden.shape[0],), 3, dtype=torch.long), + num_classes=8, + ).to(torch.float32), + ] + predictor._setup_compile = lambda: None + predictor._padded_bsz = lambda bsz: bsz + + def ensure_buffers(device, dtype, padded_bsz): + predictor._proj_buf = torch.zeros(padded_bsz, 4, 4, device=device, dtype=dtype) + + predictor._ensure_buffers = ensure_buffers + predictor._compiled_model_fwd = lambda embeds, _positions: torch.zeros_like(embeds) + + codes = predictor.forward( + torch.tensor([1]), + torch.zeros(1, 4), + torch.zeros(1, 4), + do_sample=False, + ) + + assert codes.tolist() == [[1, 2, 3]] + assert predictor.small_to_mtp_projection.call_shapes == [(1, 2, 4)] + torch.testing.assert_close( + predictor._proj_buf[0, 2], + predictor._projected_codec_embed_weight[0, 2], + ) + + filter_calls = [] + sample_calls = [] + + def apply_top_k_top_p(logits, *, p, k, top_k): + filter_calls.append((p, k, top_k)) + return logits + + def random_sample(_probs, generators): + sample_calls.append(generators) + return torch.tensor([[4]]) + + monkeypatch.setattr(module, "apply_top_k_top_p", apply_top_k_top_p) + monkeypatch.setattr(module, "random_sample", random_sample) + predictor._num_groups = 2 + predictor._wrapper_config.sampling_mode = "stored" + predictor._top_k = 2 + predictor._top_p = 0.8 + predictor._lm_heads_list = predictor._lm_heads_list[:1] + generator = torch.Generator().manual_seed(1234) + + sampled_codes = predictor.forward( + torch.tensor([1]), + torch.zeros(1, 4), + torch.zeros(1, 4), + generator=generator, + ) + + assert sampled_codes.tolist() == [[1, 4]] + assert len(filter_calls) == 1 + top_p_tensor, top_k_tensor, top_k_hint = filter_calls[0] + torch.testing.assert_close(top_p_tensor, torch.tensor([0.8])) + assert top_k_tensor.tolist() == [2] + assert top_k_hint == 2 + assert sample_calls == [{0: generator}] def test_qwen3_tts_talker_patch_uses_fp16_runtime_dtype(monkeypatch: pytest.MonkeyPatch) -> None: @@ -265,12 +420,14 @@ def test_qwen3_tts_talker_patch_uses_fp16_runtime_dtype(monkeypatch: pytest.Monk assert talker._embedding_dtype is torch.float16 assert talker._prompt_builder._embedding_dtype is torch.float16 + assert talker.talker_mtp_graph_safe is False + assert talker.talker_mtp_accepts_per_row_generators is True assert talker.load_weights([]) == {"loaded"} - assert talker.encoder.to_calls[-1] == {"dtype": torch.float16} + assert talker.encoder.to_calls[-1] == {"device": torch.device("cpu"), "dtype": torch.float32} codes = talker._encode_ref_audio_batch([np.zeros(8, dtype=np.float32)], 24000, device=torch.device("cpu")) - assert talker.encoder.last_input_dtype is torch.float16 + assert talker.encoder.last_input_dtype is torch.float32 assert len(codes) == 1 assert codes[0].dtype is torch.long assert codes[0].shape == (4, 2) @@ -311,28 +468,154 @@ def test_qwen3_tts_prompt_patch_runs_stft_frontend_on_cpu(monkeypatch: pytest.Mo assert speaker.dtype is torch.float16 -def test_qwen3_tts_mimi_codebook_quantize_uses_cpu_fp32_cdist(monkeypatch: pytest.MonkeyPatch) -> None: - module, _ = _load_qwen3_tts_patch(monkeypatch) - real_cdist = torch.cdist - captured = {} +def test_qwen3_tts_tokenizer_npu_patch_dispatches_fused_ops(monkeypatch: pytest.MonkeyPatch) -> None: + rotary_calls = [] + rms_calls = [] - def fake_cdist(x1, x2, p=2): - captured["x1_device"] = x1.device - captured["x2_device"] = x2.device - captured["x1_dtype"] = x1.dtype - captured["x2_dtype"] = x2.dtype - return real_cdist(x1, x2, p=p) + def rotary_mul(hidden_states, cos, sin): + rotary_calls.append((hidden_states, cos, sin)) + return hidden_states + 1 - monkeypatch.setattr(torch, "cdist", fake_cdist) - codebook = object.__new__(module._MimiEuclideanCodebook310P) - codebook._embed = torch.tensor([[0.0, 0.0], [2.0, 0.0]], dtype=torch.float16) + def rms_norm(hidden_states, weight, *, epsilon): + rms_calls.append((hidden_states, weight, epsilon)) + return hidden_states * weight, None - indices = codebook.quantize(torch.tensor([[0.1, 0.0], [1.8, 0.0]], dtype=torch.float16)) + _install_fake_module( + monkeypatch, + "torch_npu", + npu_rotary_mul=rotary_mul, + npu_rms_norm=rms_norm, + ) + _install_fake_module(monkeypatch, "vllm") + _install_fake_module(monkeypatch, "vllm.logger", init_logger=lambda _name: SimpleNamespace(debug=lambda *_: None)) - assert captured == { - "x1_device": torch.device("cpu"), - "x2_device": torch.device("cpu"), - "x1_dtype": torch.float32, - "x2_dtype": torch.float32, + class FakeRMSNorm(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.tensor([2.0, 3.0])) + self.variance_epsilon = 1e-5 + + def forward(self, hidden_states): + return hidden_states + + def original_rope(q, k, cos, sin): + del cos, sin + return q, k + + tokenizer = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2", + Qwen3TTSTokenizerV2DecoderRMSNorm=FakeRMSNorm, + apply_rotary_pos_emb=original_rope, + ) + tokenizer_package = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz", + modeling_qwen3_tts_tokenizer_v2=tokenizer, + ) + _install_fake_module(monkeypatch, "vllm_omni") + _install_fake_module(monkeypatch, "vllm_omni.model_executor") + _install_fake_module(monkeypatch, "vllm_omni.model_executor.models") + _install_fake_module(monkeypatch, "vllm_omni.model_executor.models.qwen3_tts") + monkeypatch.setitem( + sys.modules, + "vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz", + tokenizer_package, + ) + + path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "models" / "qwen3_tts_tokenizer_v2.py" + module = _load_source_module("vllm_omni_test_qwen3_tts_tokenizer_npu_patch", path) + module.apply_qwen3_tts_tokenizer_v2_patch() + + q = torch.zeros(1, 2, 3, 2) + k = torch.ones_like(q) + cos = torch.zeros(1, 3, 2) + sin = torch.ones_like(cos) + q_out, k_out = tokenizer.apply_rotary_pos_emb(q, k, cos, sin) + norm = FakeRMSNorm() + norm_out = norm(torch.ones(1, 2)) + + assert len(rotary_calls) == 2 + assert rotary_calls[0][1].shape == (1, 1, 3, 2) + torch.testing.assert_close(q_out, q + 1) + torch.testing.assert_close(k_out, k + 1) + assert len(rms_calls) == 1 + assert rms_calls[0][2] == pytest.approx(1e-5) + torch.testing.assert_close(norm_out, torch.tensor([[2.0, 3.0]])) + + +def test_qwen3_tts_code2wav_npu_patch_prepares_loaded_decoder(monkeypatch: pytest.MonkeyPatch) -> None: + linear_weights = [] + conv_weights = [] + + def maybe_trans_nz(weight): + linear_weights.append(weight) + return weight + + def format_cast(weight, fmt): + conv_weights.append((weight, fmt)) + return weight + + class FakeDecoder(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(4, 4) + self.conv = torch.nn.Conv1d(4, 4, 3) + self.deconv = torch.nn.ConvTranspose1d(4, 4, 4) + self.grouped_conv = torch.nn.Conv1d(4, 4, 3, groups=2) + self.cache_precompute_calls = 0 + + def precompute_snake_caches(self): + self.cache_precompute_calls += 1 + + class FakeCode2Wav: + def __init__(self, *, vllm_config, prefix=""): + self.vllm_config = vllm_config + self.prefix = prefix + self.decoder = FakeDecoder() + + def _npu_decoder_runtime_dtype(self, _device): + return torch.float16 + + def load_weights(self, weights): + assert list(weights) == [] + return {"loaded"} + + logger = SimpleNamespace(info=lambda *_: None, debug=lambda *_: None) + current_platform = SimpleNamespace(is_npu=lambda: False) + target = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav", + Qwen3TTSCode2Wav=FakeCode2Wav, + ) + _install_fake_module(monkeypatch, "torch_npu", npu_format_cast=format_cast) + _install_fake_module(monkeypatch, "vllm") + _install_fake_module(monkeypatch, "vllm.config", VllmConfig=object) + _install_fake_module(monkeypatch, "vllm.logger", init_logger=lambda _name: logger) + _install_fake_module(monkeypatch, "vllm_ascend") + _install_fake_module(monkeypatch, "vllm_ascend.utils", maybe_trans_nz=maybe_trans_nz) + _install_fake_module(monkeypatch, "vllm_omni") + _install_fake_module(monkeypatch, "vllm_omni.platforms", current_omni_platform=current_platform) + _install_fake_module(monkeypatch, "vllm_omni.model_executor") + _install_fake_module(monkeypatch, "vllm_omni.model_executor.models") + _install_fake_module(monkeypatch, "vllm_omni.model_executor.models.qwen3_tts") + + path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "models" / "qwen3_tts_code2wav.py" + module = _load_source_module("vllm_omni_test_qwen3_tts_code2wav_npu_patch", path) + module.apply_qwen3_tts_code2wav_patch() + + model = target.Qwen3TTSCode2Wav( + vllm_config=SimpleNamespace(device_config=SimpleNamespace(device=torch.device("cpu"))), + prefix="stage1", + ) + assert model.load_weights(iter(())) == {"loaded"} + + assert model.prefix == "stage1" + assert model.decoder.linear.weight.dtype is torch.float16 + assert [weight.data_ptr() for weight in linear_weights] == [model.decoder.linear.weight.data_ptr()] + assert {weight.data_ptr() for weight, _ in conv_weights} == { + model.decoder.conv.weight.data_ptr(), + model.decoder.deconv.weight.data_ptr(), } - assert indices.tolist() == [0, 1] + assert all(fmt == module._ACL_FORMAT_FRACTAL_Z for _, fmt in conv_weights) + assert model.decoder.cache_precompute_calls == 1 diff --git a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py index a8a259504..7ac2ba123 100644 --- a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py +++ b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py @@ -3,7 +3,7 @@ Shared by Qwen3-Omni and Qwen3-TTS talker models. * SDPA attention (F.scaled_dot_product_attention) with native GQA support -* HF-compatible numerics (float32 RMSNorm, float32 RoPE, separate linear layers) +* HF-compatible CPU/CUDA numerics with NPU-only fused norm/RoPE fast paths * Per-call embedding buffer to avoid cross-request aliasing * Pre-allocated position_ids (read-only, safe to persist) * torch.compile (epilogue_fusion=False) on inner transformer by default @@ -24,6 +24,7 @@ from vllm.logger import init_logger from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm_omni.diffusion.layers.custom_op import CustomOp from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) @@ -31,33 +32,48 @@ logger = init_logger(__name__) _GeneratorLike = torch.Generator | Sequence[torch.Generator | None] | None _UNIFORM_EPS = 1e-20 +if current_omni_platform.is_npu(): + import torch_npu + # =================================================================== -# HF-numerics-compatible layers for code predictor +# Portable layers for code predictor # =================================================================== # # These use plain PyTorch ops (nn.Linear, manual RMSNorm in float32, # rotate_half RoPE) to produce outputs numerically identical to the -# HuggingFace reference. vLLM's fused kernels (RMSNorm, QKVParallel, -# get_rope) introduce small precision differences that compound across -# the autoregressive steps of the code predictor, causing severe -# audio quality degradation. +# HuggingFace reference on CPU/CUDA. vLLM's fused kernels (RMSNorm, +# QKVParallel, get_rope) introduce small precision differences that compound +# across the autoregressive steps of the code predictor, causing severe audio +# quality degradation. The Ascend fused norm/RoPE kernels below are dispatched +# by the current device platform. # # See: https://github.com/vllm-project/vllm-omni/issues/2274 -class _RMSNorm(nn.Module): - """RMSNorm matching HuggingFace's implementation exactly. - - Computes variance in float32 to avoid bfloat16 precision loss. - """ +class _RMSNorm(CustomOp): + """RMSNorm with HuggingFace-compatible CPU/CUDA math and an NPU fast path.""" def __init__(self, hidden_size: int, eps: float = 1e-6) -> None: super().__init__() self.weight = nn.Parameter(torch.ones(hidden_size)) self.variance_epsilon = eps - def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + def forward_npu(self, hidden_states: torch.Tensor) -> torch.Tensor: + hidden_states, _ = torch_npu.npu_rms_norm( + hidden_states, + self.weight, + self.variance_epsilon, + ) + return hidden_states + + def forward_cuda(self, hidden_states: torch.Tensor) -> torch.Tensor: + return self.forward_native(hidden_states) + + def forward_xpu(self, hidden_states: torch.Tensor) -> torch.Tensor: + return self.forward_native(hidden_states) + + def forward_native(self, hidden_states: torch.Tensor) -> torch.Tensor: input_dtype = hidden_states.dtype hidden_states = hidden_states.to(torch.float32) variance = hidden_states.pow(2).mean(-1, keepdim=True) @@ -72,11 +88,8 @@ def _rotate_half(x: torch.Tensor) -> torch.Tensor: return torch.cat((-x2, x1), dim=-1) -class _RotaryEmbedding(nn.Module): - """RoPE matching HuggingFace's implementation exactly. - - Forces float32 computation for cos/sin, matching HF's torch.autocast(enabled=False). - """ +class _RotaryEmbedding(CustomOp): + """RoPE with HuggingFace-compatible CPU/CUDA math and cached NPU tables.""" def __init__(self, config) -> None: super().__init__() @@ -88,8 +101,24 @@ class _RotaryEmbedding(nn.Module): rope_theta = getattr(config, "rope_theta", 10000.0) inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim)) self.register_buffer("inv_freq", inv_freq, persistent=False) + if current_omni_platform.is_npu(): + max_seq = int(getattr(config, "num_code_groups", 0) or 0) + 1 + positions = torch.arange(max_seq, dtype=torch.float32) + freqs = torch.outer(positions, inv_freq) + emb = torch.cat((freqs, freqs), dim=-1) + self.register_buffer("cos_cached", emb.cos(), persistent=False) + self.register_buffer("sin_cached", emb.sin(), persistent=False) - def forward(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + def forward_npu(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + return self.cos_cached[position_ids].to(dtype=x.dtype), self.sin_cached[position_ids].to(dtype=x.dtype) + + def forward_cuda(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + return self.forward_native(x, position_ids) + + def forward_xpu(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + return self.forward_native(x, position_ids) + + def forward_native(self, x: torch.Tensor, position_ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: # position_ids: [batch, seq_len] inv_freq_expanded = self.inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1) position_ids_expanded = position_ids[:, None, :].float() @@ -145,7 +174,6 @@ class CodePredictorAttention(nn.Module): self.o_proj = nn.Linear(self.num_heads * self.head_dim, self.hidden_size, bias=False) self.q_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps) self.k_norm = _RMSNorm(self.head_dim, eps=config.rms_norm_eps) - if current_omni_platform.is_npu(): if self.max_seq > 2048: raise ValueError( @@ -168,8 +196,6 @@ class CodePredictorAttention(nn.Module): bsz: int, seq_len: int, ) -> torch.Tensor: - import torch_npu - q_f, k_f, v_f = q, k, v if self.is_gqa: k_f = ( @@ -230,10 +256,13 @@ class CodePredictorAttention(nn.Module): # cos/sin are [batch, seq_len, head_dim], need unsqueeze at dim=1 for heads cos = cos.unsqueeze(1) # [batch, 1, seq_len, head_dim] sin = sin.unsqueeze(1) - q = (q * cos) + (_rotate_half(q) * sin) - k = (k * cos) + (_rotate_half(k) * sin) - - if not current_omni_platform.is_npu(): + if current_omni_platform.is_npu(): + q = torch_npu.npu_rotary_mul(q, cos, sin) + k = torch_npu.npu_rotary_mul(k, cos, sin) + attn_out = self._forward_npu_attention(q, k, v, bsz, seq_len) + else: + q = (q * cos) + (_rotate_half(q) * sin) + k = (k * cos) + (_rotate_half(k) * sin) attn_out = F.scaled_dot_product_attention( q, k, @@ -242,8 +271,6 @@ class CodePredictorAttention(nn.Module): is_causal=True, enable_gqa=self.is_gqa, ) - else: - attn_out = self._forward_npu_attention(q, k, v, bsz, seq_len) attn_out = attn_out.transpose(1, 2).reshape(bsz, seq_len, -1) return self.o_proj(attn_out) @@ -290,10 +317,17 @@ class CodePredictorDecoderLayer(nn.Module): residual = hidden_states hidden_states = self.input_layernorm(hidden_states) hidden_states = self.self_attn(hidden_states, position_embeddings) - hidden_states = residual + hidden_states - - residual = hidden_states - hidden_states = self.post_attention_layernorm(hidden_states) + if current_omni_platform.is_npu(): + hidden_states, _, residual = torch_npu.npu_add_rms_norm( + hidden_states, + residual, + self.post_attention_layernorm.weight, + self.post_attention_layernorm.variance_epsilon, + ) + else: + hidden_states = residual + hidden_states + residual = hidden_states + hidden_states = self.post_attention_layernorm(hidden_states) hidden_states = self.mlp(hidden_states) hidden_states = residual + hidden_states return hidden_states @@ -875,7 +909,6 @@ class CodePredictorWrapper(nn.Module): # Use captured device graph if available, otherwise call compiled fn. device_graph_entry = self._device_graphs.get(graph_key) - # Run transformer (device graph replay or compiled forward) if device_graph_entry is not None: device_graph_entry[0].replay() hidden_out = device_graph_entry[1] @@ -939,6 +972,18 @@ class CodePredictorWrapper(nn.Module): # Weight loading # ------------------------------------------------------------------ + def _prepare_npu_weights(self) -> None: + from vllm_ascend.utils import maybe_trans_nz + + linear_count = 0 + with torch.no_grad(): + # Pack linear weights once for NPU matmul. + for module in self.modules(): + if isinstance(module, nn.Linear): + module.weight.data = maybe_trans_nz(module.weight.data) + linear_count += 1 + logger.info("Prepared NPU code predictor weights: linear=%d", linear_count) + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Load weights directly (no fused projection remapping needed).""" loaded: set[str] = set() @@ -965,4 +1010,7 @@ class CodePredictorWrapper(nn.Module): weight_loader(param, w) loaded.add(name) + if current_omni_platform.is_npu(): + self._prepare_npu_weights() + return loaded diff --git a/vllm_omni/platforms/npu/_310p/patch/__init__.py b/vllm_omni/platforms/npu/_310p/patch/__init__.py index b3ab637ff..5bfd42429 100644 --- a/vllm_omni/platforms/npu/_310p/patch/__init__.py +++ b/vllm_omni/platforms/npu/_310p/patch/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations _WORKER_PATCHED = False _QWEN3_TTS_TALKER_ARCH = "Qwen3TTSTalkerForConditionalGeneration" +_QWEN3_TTS_CODE2WAV_ARCH = "Qwen3TTSCode2Wav" def apply_patches() -> None: @@ -22,9 +23,13 @@ def apply_patches() -> None: def apply_model_patches(model_config) -> None: - if getattr(model_config, "model_arch", None) != _QWEN3_TTS_TALKER_ARCH: + model_arch = getattr(model_config, "model_arch", None) + if model_arch == _QWEN3_TTS_TALKER_ARCH: + from vllm_omni.platforms.npu._310p.patch.qwen3_tts import apply_talker_patches + + apply_talker_patches() return + elif model_arch == _QWEN3_TTS_CODE2WAV_ARCH: + from vllm_omni.platforms.npu._310p.patch.qwen3_tts import apply_code2wav_patches - from vllm_omni.platforms.npu._310p.patch.qwen3_tts import apply_talker_patches - - apply_talker_patches() + apply_code2wav_patches() diff --git a/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py b/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py index c43cdd3cf..713b67f81 100644 --- a/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py +++ b/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py @@ -5,26 +5,30 @@ from __future__ import annotations +from collections.abc import Sequence + import numpy as np import torch -from transformers.models.mimi import modeling_mimi +import torch.nn as nn +import torch.nn.functional as F +import torch_npu from vllm.multimodal.audio import AudioResampler +from vllm_ascend._310p.attention.attention_mask import AttentionMaskBuilder310 +from vllm_ascend.sample.sampler import apply_top_k_top_p, random_sample +from vllm_ascend.utils import ACL_FORMAT_FRACTAL_NZ, aligned_16, maybe_trans_nz, nd_to_nz_2d from vllm_omni.model_executor.models.common import qwen3_code_predictor -from vllm_omni.model_executor.models.qwen3_tts import prompt_embeds_builder, qwen3_tts_talker +from vllm_omni.model_executor.models.qwen3_tts import ( + prompt_embeds_builder, + qwen3_tts_code2wav, + qwen3_tts_code_predictor_vllm, + qwen3_tts_talker, +) _RUNTIME_DTYPE = torch.float16 _CPU_DEVICE = torch.device("cpu") - - -class _MimiEuclideanCodebook310P(modeling_mimi.MimiEuclideanCodebook): - def quantize(self, hidden_states): - # 310P does not support torch.cdist on NPU. - device = hidden_states.device - dists = torch.cdist( - hidden_states[None].to(_CPU_DEVICE, torch.float32), self.embed[None].to(_CPU_DEVICE, torch.float32), p=2 - )[0] - return dists.argmin(dim=-1).to(device=device) +_PATCHED = False +_CODE2WAV_PATCHED = False class _Qwen3TTSTalker310P(qwen3_tts_talker.Qwen3TTSTalkerForConditionalGeneration): @@ -32,10 +36,18 @@ class _Qwen3TTSTalker310P(qwen3_tts_talker.Qwen3TTSTalkerForConditionalGeneratio super().__init__(vllm_config=vllm_config, prefix=prefix) self._embedding_dtype = _RUNTIME_DTYPE self._prompt_builder._embedding_dtype = _RUNTIME_DTYPE + # 310P random sampling operators cannot be captured by ACL graphs. + # Keep Talker-MTP eager so CodePredictor can replay its own NPU graphs + # without changing sampling semantics. + self.talker_mtp_graph_safe = False + self.talker_mtp_accepts_per_row_generators = True def load_weights(self, weights): loaded = super().load_weights(weights) - self.encoder.to(dtype=_RUNTIME_DTYPE) + # The Mimi tokenizer encoder is used only while building ref_audio + # prompts. Keep this preprocessing-only module on CPU because the 310P + # NPU path is sensitive to changing reference-audio shapes. + self.encoder.to(device=_CPU_DEVICE, dtype=torch.float32) return loaded def _encode_ref_audio_batch( @@ -51,7 +63,7 @@ class _Qwen3TTSTalker310P(qwen3_tts_talker.Qwen3TTSTalkerForConditionalGeneratio resampler = AudioResampler(target_sr=target_sr) wavs = [resampler.resample(w.astype(np.float32), orig_sr=int(sr)) for w in wavs] - inputs = fe(raw_audio=wavs, sampling_rate=target_sr, return_tensors="pt").to(device).to(_RUNTIME_DTYPE) + inputs = fe(raw_audio=wavs, sampling_rate=target_sr, return_tensors="pt").to(torch.float32) with torch.inference_mode(): encoded = self.encoder.encode( @@ -100,10 +112,56 @@ class _Qwen3TTSPromptEmbedsBuilder310P(prompt_embeds_builder.Qwen3TTSPromptEmbed return spk.to(dtype=dtype) +# =================================================================== +# Code2Wav layer patches +# =================================================================== +# +# Code2Wav runs under the 310P graph path after the stage-0 Talker has +# produced codec tokens. Common NPU weight packing and fused tokenizer ops +# live in the shared model code; the 310P patch only selects the runtime dtype. + + +class _Qwen3TTSCode2Wav310P(qwen3_tts_code2wav.Qwen3TTSCode2Wav): + """Qwen3-TTS Code2Wav specialized for the 310P NPU path.""" + + def _npu_decoder_runtime_dtype(self, device: torch.device) -> torch.dtype: + return _RUNTIME_DTYPE + + +# =================================================================== +# CodePredictor layer patches +# =================================================================== +# +# Keep the portable implementation in common/qwen3_code_predictor.py. +# The overrides below are installed only by the 310P platform patch because +# the short CodePredictor loop is graph-captured on 310P and needs the 310P +# flash-attention mask layout plus loop-local projection and sampling changes. + + class _Qwen3CodePredictorAttention310P(qwen3_code_predictor.CodePredictorAttention): + """Attention override using 310P RoPE and flash-attention kernels. + + The shared attention path is written in portable PyTorch. This override + keeps the 310P-specific RoPE op, token alignment, and FRACTAL_NZ mask path + scoped to the platform patch. + """ + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._buffers.pop("_fusion_causal_mask", None) + self._q_size = self.num_heads * self.head_dim + self._kv_size = self.num_kv_heads * self.head_dim + self._fused_qkv_weight = None + self._fused_qkv_bias = None + + def prepare_qkv_weights(self) -> None: + # Pack QKV once so each graph replay uses one matmul and consumes the + # weight directly in the 310P matmul layout. + self._fused_qkv_weight = maybe_trans_nz( + torch.cat((self.q_proj.weight, self.k_proj.weight, self.v_proj.weight), dim=0).contiguous() + ) + if self.q_proj.bias is not None: + self._fused_qkv_bias = torch.cat((self.q_proj.bias, self.k_proj.bias, self.v_proj.bias), dim=0) def forward( self, @@ -111,32 +169,29 @@ class _Qwen3CodePredictorAttention310P(qwen3_code_predictor.CodePredictorAttenti position_embeddings: tuple[torch.Tensor, torch.Tensor], attention_mask: torch.Tensor | None = None, ) -> torch.Tensor: - if hidden_states.device.type != "npu" or attention_mask is None: - return super().forward(hidden_states, position_embeddings) - bsz, seq_len, _ = hidden_states.shape - hidden_shape_q = (bsz, seq_len, self.num_heads, self.head_dim) - hidden_shape_kv = (bsz, seq_len, self.num_kv_heads, self.head_dim) - - q = self.q_norm(self.q_proj(hidden_states).view(hidden_shape_q)).transpose(1, 2) - k = self.k_norm(self.k_proj(hidden_states).view(hidden_shape_kv)).transpose(1, 2) - v = self.v_proj(hidden_states).view(hidden_shape_kv).transpose(1, 2) + qkv = F.linear(hidden_states, self._fused_qkv_weight, self._fused_qkv_bias) + q, k, v = qkv.split((self._q_size, self._kv_size, self._kv_size), dim=-1) + q = self.q_norm(q.view(bsz, seq_len, self.num_heads, self.head_dim)).transpose(1, 2) + k = self.k_norm(k.view(bsz, seq_len, self.num_kv_heads, self.head_dim)).transpose(1, 2) + v = v.view(bsz, seq_len, self.num_kv_heads, self.head_dim).transpose(1, 2) cos, sin = position_embeddings cos = cos.unsqueeze(1) sin = sin.unsqueeze(1) - q = (q * cos) + (qwen3_code_predictor._rotate_half(q) * sin) - k = (k * cos) + (qwen3_code_predictor._rotate_half(k) * sin) + # Use the fused Ascend RoPE op instead of expanding RoPE into + # elementwise mul/add/rotate-half kernels. + q = torch_npu.npu_rotary_mul(q, cos, sin) + k = torch_npu.npu_rotary_mul(k, cos, sin) real_tokens = int(bsz) * int(seq_len) output_dtype = q.dtype - import torch_npu - from vllm_ascend.utils import aligned_16 - - q_f = aligned_16(q.to(torch.float16).transpose(1, 2).reshape(real_tokens, self.num_heads, self.head_dim)) - k_f = aligned_16(k.to(torch.float16).transpose(1, 2).reshape(real_tokens, self.num_kv_heads, self.head_dim)) - v_f = aligned_16(v.to(torch.float16).transpose(1, 2).reshape(real_tokens, self.num_kv_heads, self.head_dim)) + # 310P flash attention consumes token-major fp16 inputs with 16-token + # alignment; seq_lens carries the padding information. + q_f = aligned_16(q.transpose(1, 2).reshape(real_tokens, self.num_heads, self.head_dim)) + k_f = aligned_16(k.transpose(1, 2).reshape(real_tokens, self.num_kv_heads, self.head_dim)) + v_f = aligned_16(v.transpose(1, 2).reshape(real_tokens, self.num_kv_heads, self.head_dim)) aligned_tokens = int(q_f.shape[0]) seq_lens = torch.full((int(bsz),), int(seq_len), dtype=torch.int32, device="cpu") @@ -160,23 +215,34 @@ class _Qwen3CodePredictorAttention310P(qwen3_code_predictor.CodePredictorAttenti class _Qwen3CodePredictorDecoderLayer310P(qwen3_code_predictor.CodePredictorDecoderLayer): + """Decoder layer override that passes the 310P attention mask.""" + def forward( self, hidden_states: torch.Tensor, position_embeddings: tuple[torch.Tensor, torch.Tensor], - attention_mask: torch.Tensor | None = None, + attention_mask: torch.Tensor, ) -> torch.Tensor: residual = hidden_states hidden_states = self.input_layernorm(hidden_states) hidden_states = self.self_attn(hidden_states, position_embeddings, attention_mask=attention_mask) - hidden_states = residual + hidden_states - - residual = hidden_states - hidden_states = self.post_attention_layernorm(hidden_states) + hidden_states, _, residual = torch_npu.npu_add_rms_norm( + hidden_states, + residual, + self.post_attention_layernorm.weight, + self.post_attention_layernorm.variance_epsilon, + ) return residual + self.mlp(hidden_states) class _Qwen3CodePredictorBaseModel310P(qwen3_code_predictor.CodePredictorBaseModel): + """Base model override with a cached 310P causal mask. + + The 310P flash-attention path consumes the additive causal mask in + FRACTAL_NZ format. The CodePredictor sequence length is fixed and short, + so the mask is built once and reused across graph replays. + """ + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._attention_mask_310p = None @@ -188,14 +254,9 @@ class _Qwen3CodePredictorBaseModel310P(qwen3_code_predictor.CodePredictorBaseMod inputs_embeds: torch.Tensor, position_ids: torch.Tensor, ) -> torch.Tensor: - if inputs_embeds.device.type != "npu": - return super().forward(inputs_embeds, position_ids) - if self._attention_mask_310p is None or self._attention_mask_310p_device != inputs_embeds.device: - import torch_npu - from vllm_ascend._310p.attention.attention_mask import AttentionMaskBuilder310 - from vllm_ascend.utils import ACL_FORMAT_FRACTAL_NZ, nd_to_nz_2d - + # Store the additive causal mask in the format consumed by the + # 310P flash-attention kernel. mask = AttentionMaskBuilder310.gen_causal_additive_mask( self._attention_mask_310p_max_seq, inputs_embeds.device, @@ -205,23 +266,239 @@ class _Qwen3CodePredictorBaseModel310P(qwen3_code_predictor.CodePredictorBaseMod input_dtype = inputs_embeds.dtype hidden_states = inputs_embeds - with torch.amp.autocast(inputs_embeds.device.type, enabled=False, dtype=torch.float32): - position_embeddings = self.rotary_emb(hidden_states, position_ids) - for layer in self.layers: - hidden_states = layer( - hidden_states, - position_embeddings, - attention_mask=self._attention_mask_310p, - ) - hidden_states = self.norm(hidden_states) + position_embeddings = self.rotary_emb(hidden_states, position_ids) + for layer in self.layers: + hidden_states = layer( + hidden_states, + position_embeddings, + attention_mask=self._attention_mask_310p, + ) + hidden_states = self.norm(hidden_states) return hidden_states.to(input_dtype) +class _Qwen3TTSTalkerCodePredictor310P( + qwen3_tts_code_predictor_vllm.Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM +): + """Qwen3-TTS code predictor specialized for the 310P NPU path.""" + + def __init__( + self, + *, + vllm_config, + config, + talker_config, + prefix: str = "code_predictor", + ) -> None: + super().__init__( + vllm_config=vllm_config, + config=config, + talker_config=talker_config, + prefix=prefix, + ) + self._projected_codec_embed_weight = None + + def _prepare_npu_weights(self) -> None: + qkv_projections = set() + with torch.no_grad(): + for layer in self.model.layers: + attention = layer.self_attn + attention.prepare_qkv_weights() + qkv_projections.update((attention.q_proj, attention.k_proj, attention.v_proj)) + + for module in self.modules(): + if isinstance(module, nn.Linear) and module not in qkv_projections: + module.weight.data = maybe_trans_nz(module.weight.data) + + def load_weights(self, weights): + loaded = super().load_weights(weights) + with torch.no_grad(): + self._projected_codec_embed_weight = torch.stack( + [self.small_to_mtp_projection(embed.weight).detach() for embed in self.model.codec_embedding], + dim=0, + ).contiguous() + return loaded + + @torch.inference_mode() + def forward( + self, + layer0_code: torch.Tensor, + layer0_embed: torch.Tensor, + last_talker_hidden: torch.Tensor, + do_sample: bool = True, + temperature: float = 0.9, + top_k: int = 50, + top_p: float = 1.0, + generator: torch.Generator | None = None, + generators: Sequence[torch.Generator | None] | None = None, + ) -> torch.Tensor: + bsz = int(layer0_code.shape[0]) + if generators is not None and len(generators) != bsz: + raise ValueError(f"generators must have one entry per row: got {len(generators)} for batch {bsz}") + num_groups = self._num_groups + device = layer0_code.device + + self._setup_compile() + dtype = self._model_dtype + + padded_bsz = self._padded_bsz(bsz) + self._ensure_buffers(device, dtype, padded_bsz) + + proj_buf = self._proj_buf + max_seq = num_groups + 1 + projection = self.small_to_mtp_projection + model_fwd = self._compiled_model_fwd + lm_heads = self._lm_heads_list + if generators is not None: + npu_generators = {i: row_generator for i, row_generator in enumerate(generators) if row_generator} + elif generator is not None: + npu_generators = {i: generator for i in range(bsz)} + else: + npu_generators = {} + + proj_buf[:padded_bsz].zero_() + initial_embeds = torch.cat( + ( + last_talker_hidden.reshape(bsz, 1, -1), + layer0_embed.reshape(bsz, 1, -1), + ), + dim=1, + ) + if initial_embeds.dtype != dtype: + initial_embeds = initial_embeds.to(dtype) + proj_buf[:bsz, :2, :].copy_(projection(initial_embeds)) + + stored_mode = self._wrapper_config.sampling_mode == "stored" + if stored_mode: + s_top_k = self._top_k + s_top_p = self._top_p + else: + use_sampling = do_sample and temperature > 0 + inv_temperature = 1.0 / max(temperature, 1e-6) if use_sampling else 0.0 + if use_sampling and top_p != 1.0: + raise NotImplementedError( + "top_p sampling is not implemented for the vLLM-native code predictor; please set top_p=1.0." + ) + + top_k_tensor = None + top_p_tensor = None + if stored_mode: + top_k_hint = s_top_k if s_top_k > 0 else None + if s_top_k > 0: + top_k_tensor = torch.full((bsz,), s_top_k, dtype=torch.int32, device=device) + if s_top_p < 1.0: + top_p_tensor = torch.full((bsz,), s_top_p, dtype=dtype, device=device) + elif use_sampling: + top_k_hint = top_k if top_k > 0 else None + if top_k > 0: + top_k_tensor = torch.full((bsz,), top_k, dtype=torch.int32, device=device) + else: + top_k_hint = None + + all_codes = torch.empty(bsz, num_groups, dtype=torch.long, device=device) + all_codes[:, 0] = layer0_code.reshape(bsz) + + for step in range(1, num_groups): + graph_key: int | tuple[int, int] = padded_bsz + seq_len = max_seq + if self._prefix_graphs_enabled: + prefix_key = (padded_bsz, step + 1) + if prefix_key in self._device_graphs: + graph_key = prefix_key + seq_len = step + 1 + pos_ids = self._bucket_pos_ids.get(graph_key) + if pos_ids is None: + pos_ids = ( + torch.arange(seq_len, device=device, dtype=torch.long) + .unsqueeze(0) + .expand(padded_bsz, -1) + .contiguous() + ) + + device_graph_entry = self._device_graphs.get(graph_key) + if device_graph_entry is not None: + device_graph_entry[0].replay() + hidden_out = device_graph_entry[1] + else: + hidden_out = model_fwd(proj_buf[:padded_bsz, :seq_len, :], pos_ids) + + logits = lm_heads[step - 1](hidden_out[:bsz, step, :]) + + if stored_mode: + if top_k_tensor is not None or top_p_tensor is not None: + logits = apply_top_k_top_p(logits, p=top_p_tensor, k=top_k_tensor, top_k=top_k_hint) + candidate_indices = None + if isinstance(logits, tuple): + logits, candidate_indices = logits + probs = F.softmax(logits, dim=-1, dtype=torch.float32) + code = random_sample(probs, npu_generators) + if candidate_indices is not None: + code = candidate_indices.gather(1, code.unsqueeze(1)).squeeze(1) + else: + if use_sampling: + scaled = logits * inv_temperature + if top_k_tensor is not None: + scaled = apply_top_k_top_p(scaled, p=None, k=top_k_tensor, top_k=top_k_hint) + candidate_indices = None + if isinstance(scaled, tuple): + scaled, candidate_indices = scaled + probs = F.softmax(scaled, dim=-1, dtype=torch.float32) + code = random_sample(probs, npu_generators) + if candidate_indices is not None: + code = candidate_indices.gather(1, code.unsqueeze(1)).squeeze(1) + else: + code = logits.argmax(dim=-1, keepdim=True) + + all_codes[:, step] = code.reshape(bsz) + if step < num_groups - 1: + proj_buf[:bsz, step + 1, :].copy_( + F.embedding(code.reshape(-1), self._projected_codec_embed_weight[step - 1]) + ) + + return all_codes + + +# =================================================================== +# Patch registration +# =================================================================== + + def apply_talker_patches() -> None: - modeling_mimi.MimiEuclideanCodebook = _MimiEuclideanCodebook310P + """Install Qwen3-TTS Talker and CodePredictor 310P patches. + + The generic model modules stay unchanged. Patch registration swaps in the + 310P-specialized CodePredictor classes and wrapper methods only when the + 310P platform applies the Talker patch. + """ + + global _PATCHED + + if _PATCHED: + return + qwen3_tts_talker.Qwen3TTSTalkerForConditionalGeneration = _Qwen3TTSTalker310P qwen3_tts_talker.Qwen3TTSPromptEmbedsBuilder = _Qwen3TTSPromptEmbedsBuilder310P + qwen3_tts_talker.Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM = _Qwen3TTSTalkerCodePredictor310P + qwen3_tts_code_predictor_vllm.Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM = ( + _Qwen3TTSTalkerCodePredictor310P + ) + qwen3_tts_code_predictor_vllm.CodePredictorBaseModel = _Qwen3CodePredictorBaseModel310P + qwen3_tts_code_predictor_vllm.Qwen3TTSTalkerCodePredictorModelVLLM = _Qwen3CodePredictorBaseModel310P prompt_embeds_builder.Qwen3TTSPromptEmbedsBuilder = _Qwen3TTSPromptEmbedsBuilder310P qwen3_code_predictor.CodePredictorAttention = _Qwen3CodePredictorAttention310P qwen3_code_predictor.CodePredictorDecoderLayer = _Qwen3CodePredictorDecoderLayer310P qwen3_code_predictor.CodePredictorBaseModel = _Qwen3CodePredictorBaseModel310P + + _PATCHED = True + + +def apply_code2wav_patches() -> None: + """Install the 310P Code2Wav runtime patch.""" + global _CODE2WAV_PATCHED + + if _CODE2WAV_PATCHED: + return + + qwen3_tts_code2wav.Qwen3TTSCode2Wav = _Qwen3TTSCode2Wav310P + + _CODE2WAV_PATCHED = True diff --git a/vllm_omni/platforms/npu/_310p/patch/worker.py b/vllm_omni/platforms/npu/_310p/patch/worker.py index 3116b54da..49ee6052c 100644 --- a/vllm_omni/platforms/npu/_310p/patch/worker.py +++ b/vllm_omni/platforms/npu/_310p/patch/worker.py @@ -12,6 +12,9 @@ compile-mode setup is applied here after device initialization. from __future__ import annotations +from vllm.v1.sample.sampler import Sampler +from vllm_ascend._310p.sample.sampler import AscendSampler310 + from vllm_omni.platforms.npu._310p import disable_jit_compile from vllm_omni.platforms.npu.worker import base as worker_base @@ -24,4 +27,6 @@ class _OmniNPUWorkerBase310P(worker_base.OmniNPUWorkerBase): def apply_patch() -> None: + # Triton-Ascend does not target 310P; use vLLM's native penalty path. + AscendSampler310.apply_penalties = staticmethod(Sampler.apply_penalties) worker_base.OmniNPUWorkerBase = _OmniNPUWorkerBase310P diff --git a/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py b/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py index 282689bca..bd74e9dc6 100644 --- a/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py +++ b/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py @@ -1,15 +1,18 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Monkey-patch ``Qwen3TTSCode2Wav.__init__`` for NPU Code2Wav runtime knobs.""" +"""Patch Qwen3-TTS Code2Wav NPU runtime setup and weight preparation.""" from __future__ import annotations from typing import TYPE_CHECKING import torch +import torch.nn as nn +import torch_npu from vllm.config import VllmConfig from vllm.logger import init_logger +from vllm_ascend.utils import maybe_trans_nz if TYPE_CHECKING: pass @@ -18,6 +21,8 @@ logger = init_logger(__name__) _PATCHED = False _original_init = None +_original_load_weights = None +_ACL_FORMAT_FRACTAL_Z = 4 def _prepare_npu_code2wav_runtime() -> None: @@ -35,14 +40,43 @@ def _patched_init(self, *, vllm_config: VllmConfig, prefix: str = "") -> None: _original_init(self, vllm_config=vllm_config, prefix=prefix) +def _prepare_npu_decoder_weights(decoder: nn.Module) -> None: + linear_count = 0 + conv_count = 0 + with torch.no_grad(): + for module in decoder.modules(): + if isinstance(module, nn.Linear): + module.weight.data = maybe_trans_nz(module.weight.data) + linear_count += 1 + elif isinstance(module, (nn.Conv1d, nn.ConvTranspose1d)) and module.groups == 1: + module.weight.data = torch_npu.npu_format_cast(module.weight.data.contiguous(), _ACL_FORMAT_FRACTAL_Z) + conv_count += 1 + + logger.info("Prepared NPU Code2Wav weights: linear=%d conv=%d", linear_count, conv_count) + + +def _patched_load_weights(self, weights): + assert _original_load_weights is not None + loaded = _original_load_weights(self, weights) + device = self.vllm_config.device_config.device + runtime_dtype = getattr(self, "_npu_decoder_runtime_dtype", lambda _: torch.float32)(device) + self.decoder.to(device=device, dtype=runtime_dtype) + _prepare_npu_decoder_weights(self.decoder) + if runtime_dtype != torch.float32 and hasattr(self.decoder, "precompute_snake_caches"): + self.decoder.precompute_snake_caches() + return loaded + + def apply_qwen3_tts_code2wav_patch() -> None: - global _PATCHED, _original_init + global _PATCHED, _original_init, _original_load_weights if _PATCHED: return from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav import Qwen3TTSCode2Wav _original_init = Qwen3TTSCode2Wav.__init__ + _original_load_weights = Qwen3TTSCode2Wav.load_weights Qwen3TTSCode2Wav.__init__ = _patched_init # type: ignore[method-assign] + Qwen3TTSCode2Wav.load_weights = _patched_load_weights # type: ignore[method-assign] _PATCHED = True - logger.debug("Applied NPU patch for Qwen3TTSCode2Wav.__init__") + logger.debug("Applied NPU patch for Qwen3TTSCode2Wav") diff --git a/vllm_omni/platforms/npu/models/qwen3_tts_tokenizer_v2.py b/vllm_omni/platforms/npu/models/qwen3_tts_tokenizer_v2.py new file mode 100644 index 000000000..f89ecded9 --- /dev/null +++ b/vllm_omni/platforms/npu/models/qwen3_tts_tokenizer_v2.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +"""NPU patches for the Qwen3-TTS 12Hz tokenizer decoder.""" + +from __future__ import annotations + +import torch +import torch_npu +from vllm.logger import init_logger + +logger = init_logger(__name__) + +_PATCHED = False + + +def _apply_rotary_pos_emb_npu( + q: torch.Tensor, + k: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + position_ids=None, + unsqueeze_dim: int = 1, +) -> tuple[torch.Tensor, torch.Tensor]: + del position_ids + cos = cos.unsqueeze(unsqueeze_dim) + sin = sin.unsqueeze(unsqueeze_dim) + return torch_npu.npu_rotary_mul(q, cos, sin), torch_npu.npu_rotary_mul(k, cos, sin) + + +def _rms_norm_forward_npu(self, hidden_states: torch.Tensor) -> torch.Tensor: + return torch_npu.npu_rms_norm(hidden_states, self.weight, epsilon=self.variance_epsilon)[0] + + +def apply_qwen3_tts_tokenizer_v2_patch() -> None: + global _PATCHED + if _PATCHED: + return + + from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz import ( + modeling_qwen3_tts_tokenizer_v2, + ) + + modeling_qwen3_tts_tokenizer_v2.apply_rotary_pos_emb = _apply_rotary_pos_emb_npu + modeling_qwen3_tts_tokenizer_v2.Qwen3TTSTokenizerV2DecoderRMSNorm.forward = _rms_norm_forward_npu + _PATCHED = True + logger.debug("Applied NPU patch for Qwen3-TTS 12Hz tokenizer decoder") diff --git a/vllm_omni/platforms/npu/platform.py b/vllm_omni/platforms/npu/platform.py index 30aafdb32..fe49455d5 100644 --- a/vllm_omni/platforms/npu/platform.py +++ b/vllm_omni/platforms/npu/platform.py @@ -37,8 +37,12 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform): from vllm_omni.platforms.npu.models.qwen3_tts_code2wav import ( apply_qwen3_tts_code2wav_patch, ) + from vllm_omni.platforms.npu.models.qwen3_tts_tokenizer_v2 import ( + apply_qwen3_tts_tokenizer_v2_patch, + ) apply_qwen3_tts_code2wav_patch() + apply_qwen3_tts_tokenizer_v2_patch() apply_310p_patches() @classmethod From f84f0e72fd10f6e61a6db4284cc6a8f5dec0a629 Mon Sep 17 00:00:00 2001 From: Honghan Zhu <zhuhonghan@huawei.com> Date: Wed, 15 Jul 2026 13:08:15 +0800 Subject: [PATCH 088/101] [Bug Fix] Hunyuan image cfg bugfix (#4752) Signed-off-by: zhuhh97 <zhuhonghan@huawei.com> Signed-off-by: dph97 <nenbaying@163.com> Co-authored-by: zhuhh97 <z00806815@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com> --- docker/Dockerfile.cuda | 2 +- docker/Dockerfile.rocm | 2 +- .../diffusion/test_diffusion_model_runner.py | 19 +++++ .../omni_connectors/test_tp_rank_aware.py | 70 +++++++++++++++++++ .../distributed/group_coordinator.py | 28 +++++--- .../hunyuan_image3_transformer.py | 10 +++ .../omni_connectors/kv_transfer_manager.py | 14 +++- 7 files changed, 132 insertions(+), 13 deletions(-) diff --git a/docker/Dockerfile.cuda b/docker/Dockerfile.cuda index 55d5859a8..4e9075f2a 100644 --- a/docker/Dockerfile.cuda +++ b/docker/Dockerfile.cuda @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=vllm/vllm-openai:v0.24.0 +ARG BASE_IMAGE=vllm/vllm-openai:v0.25.0 FROM ${BASE_IMAGE} ARG COMMON_WORKDIR=/app diff --git a/docker/Dockerfile.rocm b/docker/Dockerfile.rocm index 7c45cb770..49c6f02aa 100644 --- a/docker/Dockerfile.rocm +++ b/docker/Dockerfile.rocm @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.24.0 +ARG BASE_IMAGE=vllm/vllm-openai-rocm:v0.25.0 FROM ${BASE_IMAGE} AS base # Declare a variable to know if we want to use the nightly build or the stable build. diff --git a/tests/diffusion/test_diffusion_model_runner.py b/tests/diffusion/test_diffusion_model_runner.py index 55584609d..5d4ef5d72 100644 --- a/tests/diffusion/test_diffusion_model_runner.py +++ b/tests/diffusion/test_diffusion_model_runner.py @@ -509,6 +509,25 @@ def test_split_diffusion_output_by_request_slices_tuple_outputs(): assert torch.equal(outputs[1].output[1], torch.tensor([20])) +@pytest.mark.core_model +@pytest.mark.cpu +def test_execute_model_runs_forward_after_kv_receive(monkeypatch): + """execute_model runs the pipeline forward after the KV receive step.""" + runner = _make_runner(cache_backend=None, cache_backend_name=None) + runner.kv_transfer_manager.receive_multi_kv_cache_distributed = lambda *a, **k: True + req = _make_request(skip_cache_refresh=True) + + monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) + monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None) + monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0) + monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0) + + output = DiffusionModelRunner.execute_model(runner, req) + + assert output.output == "ok" + assert runner.pipeline.forward_calls == 1 + + @pytest.mark.core_model @pytest.mark.cpu def test_load_model_clears_cache_backend_for_unsupported_pipeline(monkeypatch): diff --git a/tests/distributed/omni_connectors/test_tp_rank_aware.py b/tests/distributed/omni_connectors/test_tp_rank_aware.py index 8f8783db2..261de0b32 100644 --- a/tests/distributed/omni_connectors/test_tp_rank_aware.py +++ b/tests/distributed/omni_connectors/test_tp_rank_aware.py @@ -937,6 +937,76 @@ class TestDistributedReceive: # ── TP auto-detect ─────────────────────────────────────────────────── +class TestBuildCfgRankLocalPayloads: + """Guard _build_cfg_rank_local_payloads padding logic for the + cfg_size==2 / no branch_roles scenario (hunyuan-image shared-prefix reuse).""" + + def test_cfg_size2_no_branch_roles_copies_main_payload_to_follower(self): + """When AR doesn't split KV by branch, cfg follower (rank 1) should + receive a copy of the positive KV, not None.""" + req = SimpleNamespace( + request_id="req-1", + past_key_values=SimpleNamespace(key_cache=[torch.tensor([1.0])]), + kv_metadata={"seq_len": 10}, + sampling_params=SimpleNamespace(), + ) + payloads = OmniKVTransferManager._build_cfg_rank_local_payloads(req, cfg_size=2) + + assert len(payloads) == 2 + # Rank 0: main payload (always present) + assert payloads[0] is not None + assert "past_key_values" in payloads[0] + # Rank 1: must also be non-None — same positive KV for shared-prefix reuse + assert payloads[1] is not None + assert payloads[1]["past_key_values"] is req.past_key_values + assert payloads[1]["kv_metadata"] == {"seq_len": 10} + + def test_cfg_size3_no_branch_roles_keeps_none_padding(self): + """When cfg_size > 2 and no branch_roles, padding stays None (safety guard).""" + req = SimpleNamespace( + request_id="req-1", + past_key_values=SimpleNamespace(key_cache=[torch.tensor([1.0])]), + kv_metadata={"seq_len": 10}, + sampling_params=SimpleNamespace(), + ) + payloads = OmniKVTransferManager._build_cfg_rank_local_payloads(req, cfg_size=3) + + assert len(payloads) == 3 + assert payloads[0] is not None + assert payloads[1] is None + assert payloads[2] is None + + def test_empty_main_payload_keeps_none_padding(self): + """When main_payload is empty, padding stays None even with cfg_size==2.""" + req = SimpleNamespace( + request_id="req-1", + sampling_params=SimpleNamespace(), + ) + payloads = OmniKVTransferManager._build_cfg_rank_local_payloads(req, cfg_size=2) + + assert len(payloads) == 2 + # No past_key_values → main_payload is empty dict → falsy → padding stays None + assert payloads[1] is None + + def test_with_branch_roles_uses_original_behavior(self): + """When branch_roles exist, follower payload comes from branch KV, not padding.""" + req = SimpleNamespace( + request_id="req-1", + past_key_values=SimpleNamespace(key_cache=[torch.tensor([1.0])]), + kv_metadata={"seq_len": 10}, + sampling_params=SimpleNamespace( + cfg_text_past_key_values=SimpleNamespace(key_cache=[torch.tensor([2.0])]), + cfg_text_kv_metadata={"source": "cfg_text"}, + ), + ) + payloads = OmniKVTransferManager._build_cfg_rank_local_payloads(req, cfg_size=2) + + assert len(payloads) == 2 + assert payloads[0] is not None + assert payloads[1] is not None + assert payloads[1]["sp.cfg_active_branch"] == "cfg_text" + + class TestAutoDetectTP: def test_auto_detect_when_config_defaults(self): """When config from_tp/to_tp == 1 (default), manager should auto-detect.""" diff --git a/vllm_omni/diffusion/distributed/group_coordinator.py b/vllm_omni/diffusion/distributed/group_coordinator.py index 5c5b092ef..0a67fd941 100644 --- a/vllm_omni/diffusion/distributed/group_coordinator.py +++ b/vllm_omni/diffusion/distributed/group_coordinator.py @@ -589,11 +589,7 @@ class GroupCoordinator: if dst is None: dst = self.group_next_rank - torch.distributed.send( - tensor, - self.ranks[dst], - group=(self.device_groups[self.rank_in_group % 2] if self.world_size == 2 else self.device_group), - ) + torch.distributed.send(tensor, self.ranks[dst], group=self.device_group) def recv(self, size: torch.Size, dtype: torch.dtype, src: int | None = None) -> torch.Tensor: """Receives a tensor from the src rank.""" @@ -602,11 +598,7 @@ class GroupCoordinator: src = self.group_prev_rank tensor = torch.empty(size, dtype=dtype, device=self.device) - torch.distributed.recv( - tensor, - self.ranks[src], - (self.device_groups[(self.rank_in_group + 1) % 2] if self.world_size == 2 else self.device_group), - ) + torch.distributed.recv(tensor, self.ranks[src], self.device_group) return tensor def destroy(self): @@ -710,6 +702,22 @@ class PipelineGroupCoordinator(GroupCoordinator): self.skip_device_group = skip_device_group assert self.skip_device_group is not None + def send(self, tensor: torch.Tensor, dst: int | None = None) -> None: + if dst is None: + dst = self.group_next_rank + + group = self.device_groups[self.rank_in_group % 2] if self.world_size == 2 else self.device_group + torch.distributed.send(tensor, self.ranks[dst], group=group) + + def recv(self, size: torch.Size, dtype: torch.dtype, src: int | None = None) -> torch.Tensor: + if src is None: + src = self.group_prev_rank + + tensor = torch.empty(size, dtype=dtype, device=self.device) + group = self.device_groups[(self.rank_in_group + 1) % 2] if self.world_size == 2 else self.device_group + torch.distributed.recv(tensor, self.ranks[src], group) + return tensor + def reset_buffer(self): self.recv_tasks_queue = [] self.receiving_tasks = [] 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 0416fdcf1..f38d1ca7c 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py @@ -1099,6 +1099,12 @@ class ImageKVCacheManager: neg_kv = (key.reshape(-1, num_kv_heads, head_dim), value.reshape(-1, num_kv_heads, head_dim)) self._injected_ar_kv = [self._injected_ar_kv[0], neg_kv] + logger.debug( + "[AR KV Reuse] neg branch: reused shared_prefix_len=%d, neg_kv_total_len=%d (q_len_actual=%d)", + shared_prefix_len, + key.shape[1], + q_len_actual, + ) return key, value def __call__( @@ -2919,6 +2925,10 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline): ): ar_kv_data = model_kwargs.pop("ar_kv_data", None) if ar_kv_data is None: + logger.debug( + "[AR KV Reuse] cfg_rank=%s: no AR KV received, fallback to full recompute (reuse_len=0)", + cfg_rank, + ) return input_ids, 0 # 1. positive prefix len diff --git a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py index 304489bbb..51e604336 100644 --- a/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py +++ b/vllm_omni/distributed/omni_connectors/kv_transfer_manager.py @@ -856,8 +856,20 @@ class OmniKVTransferManager: payloads.append(local_payload) + padding = None + if not branch_roles and cfg_size == 2 and main_payload: + # AR didn't split KV by branch; cfg follower needs the same + # positive KV for shared-prefix reuse. Only safe for cfg_size == 2: + # for cfg_size > 2 every rank must get its own KV, which we can't + # assign here, so padding stays None and the runner raises. + # Shallow dict copy: shares PKV tensors with main_payload. Safe + # only because the send path (cfg_scatter -> pack -> to_gpu_tensor) + # reads tensors to serialize, never mutates in place. Any async/ + # KV-pool path that mutates req.past_key_values mid-scatter must + # clone tensors here instead. + padding = dict(main_payload) while len(payloads) < cfg_size: - payloads.append(None) + payloads.append(padding) return payloads[:cfg_size] From 795e4e0160c06ded82d25d0a11f37cf89ee3bb58 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:18:58 +0800 Subject: [PATCH 089/101] [CI] Add local job runner scripts for L2-L4 pytest execution (#4672) Signed-off-by: wangyu <410167048@qq.com> --- docs/contributing/ci/test_guide.md | 137 +++- .../perf/scripts/run_diffusion_benchmark.py | 2 +- tools/nightly/run_nightly_jobs.sh | 350 +++++++++- tools/run_jobs_common.sh | 653 ++++++++++++++++++ tools/run_merge_jobs.sh | 49 ++ tools/run_ready_jobs.sh | 49 ++ 6 files changed, 1177 insertions(+), 63 deletions(-) create mode 100644 tools/run_jobs_common.sh create mode 100644 tools/run_merge_jobs.sh create mode 100644 tools/run_ready_jobs.sh diff --git a/docs/contributing/ci/test_guide.md b/docs/contributing/ci/test_guide.md index 0db814e08..26996129e 100644 --- a/docs/contributing/ci/test_guide.md +++ b/docs/contributing/ci/test_guide.md @@ -12,12 +12,37 @@ When running test cases, you may need to install the following dependencies: ```bash uv pip install ".[dev]" -apt-get install -y ffmpeg +apt-get install -y espeak-ng jq ``` ## Running Tests Our test scripts use the pytest framework. First, please use `git clone https://github.com/vllm-project/vllm-omni.git` to download the vllm-omni source code. Then, in the root directory of vllm-omni, you can run the following commands in your local test environment to execute the corresponding test cases. +### CI job runners (L2–L4): logs, timing, and timeouts + +[`tools/run_ready_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_ready_jobs.sh), [`tools/run_merge_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_merge_jobs.sh), and [`tools/nightly/run_nightly_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/nightly/run_nightly_jobs.sh) share the same run-time behavior (via [`tools/run_jobs_common.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_jobs_common.sh)): + +**Log layout** (default under `logs/ready_jobs`, `logs/merge_jobs`, or `logs/nightly_jobs`; override with `--log-dir`): + +| Path | Description | +|------|-------------| +| `jobs/*.sh` | Generated per-step bash wrappers | +| `jobs/.job_timeouts` | Job key → `timeout_in_minutes` from Buildkite YAML (when set) | +| `<job_key>.log` | Tee output for each job | +| `timing_summary.log` | Per-job duration and total wall time after the run completes | + +**Timing:** while each job runs, stderr prints `finished in …` or `failed after …`. When all jobs finish, a summary is printed and written to `timing_summary.log`, for example: + +``` +=== Job timing summary === + TTS_VoxCPM2_Test 25m 40s OK + Omni_Qwen3-Omni_Test 8m 05s TIMED OUT +Total wall time: 33m 45s (2 jobs) +Failed jobs: 1/2 +``` + +**Timeouts:** if a Buildkite step defines `timeout_in_minutes`, the runner wraps that job with `timeout ${N}m` (aligned with CI). Steps without `timeout_in_minutes` rely on any inline `timeout …` already present in the extracted pytest command. On timeout the process exits with code `124` and the summary marks the job as `TIMED OUT`. + === "L1 level" ```bash @@ -28,61 +53,109 @@ Our test scripts use the pytest framework. First, please use `git clone https:// === "L2 level" + **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_ready_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_ready_jobs.sh). The script reads [`.buildkite/test-ready.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-ready.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/ready_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts) for timing and timeout behavior). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + + ```bash + # All L2 and L1 jobs (default: every pytest step in test-ready.yml) + bash tools/run_ready_jobs.sh + + # Skip L1-style "Simple Test" steps in test-ready.yml (group or "Simple ·" labels) + bash tools/run_ready_jobs.sh --skip-simple + bash tools/run_ready_jobs.sh --skip-simple --model-type diffusion + + # Preview extracted commands without running + bash tools/run_ready_jobs.sh --dry-run + + # Filter by model area (OR semantics): omni | tts | diffusion | all + bash tools/run_ready_jobs.sh --model-type omni + bash tools/run_ready_jobs.sh --model-type tts,diffusion + + # Match a Buildkite label substring (e.g. one model suite) + bash tools/run_ready_jobs.sh --model-type tts --label-substr "VoxCPM2" + + ``` + + **Ad hoc:** run a single test file or marker expression manually: + ```bash cd tests - pytest -s -v -m "core_model and not cpu" --run-level=core_model - ``` - If you only want to run a specific test case, you can use: - ```bash pytest -s -v test_xxxx.py --run-level=core_model + pytest -s -v -m "core_model and distributed_cuda and L4" --run-level=core_model ``` - If you only want to run specific test cases on a particular platform, you can use: - ```bash - pytest -s -v -m "core_model and distributed_cuda and L4" --run-level=core_model - ``` - The latest test commands for various test suites can be found in the [pipeline](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-ready.yml). === "L3 level" + **Recommended:** run CI-aligned jobs from the repo root via [`tools/run_merge_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/run_merge_jobs.sh). The script reads [`.buildkite/test-merge.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-merge.yml), generates per-step bash wrappers, runs pytest, and tees logs under `logs/merge_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts)). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + ```bash - pytest -s -v -m "advanced_model" --run-level=advanced_model + # All L3 and L1 jobs (default: every pytest step in test-merge.yml) + bash tools/run_merge_jobs.sh + + # Skip L1-style "Simple Test" steps in test-merge.yml (group or "Simple ·" labels) + bash tools/run_merge_jobs.sh --skip-simple + bash tools/run_merge_jobs.sh --skip-simple --model-type omni + + # Preview extracted commands without running + bash tools/run_merge_jobs.sh --dry-run + + # Filter by model area (OR semantics): omni | tts | diffusion | all + bash tools/run_merge_jobs.sh --model-type diffusion + bash tools/run_merge_jobs.sh --model-type omni,tts + + # Match a Buildkite label substring + bash tools/run_merge_jobs.sh --model-type diffusion --label-substr "Wan22" + ``` - If you only want to run a specific test case, you can use: + + **Ad hoc:** run a single test file or marker expression manually: + ```bash pytest -s -v test_xxxx.py --run-level=advanced_model + pytest -s -v -m "advanced_model and distributed_cuda and L4" --run-level=advanced_model ``` - If you only want to run specific test cases on a particular platform, you can use: - ```bash - pytest -s -v -m "advanced_model and distributed_cuda and L4" --run-level=advanced_model - ``` - The latest L3 test commands for various test suites can be found in the [pipeline](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-merge.yml). === "L4 level" + **Recommended:** run CI-aligned nightly jobs from the repo root via [`tools/nightly/run_nightly_jobs.sh`](https://github.com/vllm-project/vllm-omni/blob/main/tools/nightly/run_nightly_jobs.sh). The script reads [`.buildkite/test-nightly.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly.yml), generates per-step bash wrappers, runs pytest (perf jobs first, then aggregates perf JSON into Excel), and tees logs under `logs/nightly_jobs/` (see [CI job runners](#ci-job-runners-l2l4-logs-timing-and-timeouts); `generate_nightly_perf_excel` is included in the timing summary). Requires `bash`, `python3`, and PyYAML (`pip install pyyaml`). + ```bash - cd tests - pytest -s -v -m "full_model" --run-level=full_model + # All nightly jobs (default: test-type all, model-type all) + bash tools/nightly/run_nightly_jobs.sh + + # Preview extracted commands without running + bash tools/nightly/run_nightly_jobs.sh --dry-run + + # Test kind (--test-type, OR semantics): perf | acc | function | stability | local | all + bash tools/nightly/run_nightly_jobs.sh --test-type function + bash tools/nightly/run_nightly_jobs.sh --test-type perf,acc + + # Model area (--model-type, OR semantics): omni | tts | diffusion | all + bash tools/nightly/run_nightly_jobs.sh --test-type function --model-type omni + bash tools/nightly/run_nightly_jobs.sh --test-type perf --model-type diffusion + + # Stability scripts (tests/dfx/stability/scripts/) and local marker tests + bash tools/nightly/run_nightly_jobs.sh --test-type stability --model-type omni + bash tools/nightly/run_nightly_jobs.sh --test-type local --model-type tts ``` - If you only want to run a specific test case, you can use: - ```bash - pytest -s -v test_xxxx.py --run-level=full_model - ``` - If you only want to run specific test cases on a particular platform, you can use: - ```bash - pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model - ``` - Note: To run performance tests (defaults to ``test_qwen_omni.json``; use ``--test-config-file tests/dfx/perf/tests/test_tts.json`` for TTS): - ```bash - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py - ``` - The latest L4 (nightly) test commands use the `full_model` marker and `--run-level full_model` (see [test-nightly.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly.yml) and [test-nightly-diffusion.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly-diffusion.yml)). Example: + + Perf steps (label contains ``Perf Test``) run before other jobs; Excel output is written under ``logs/``. Diffusion nightly steps may also appear in [test-nightly-diffusion.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly-diffusion.yml) on CI. + + **Ad hoc:** run a single test file or marker expression manually: ```bash cd tests + pytest -s -v test_xxxx.py --run-level=full_model + pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model pytest -s -v -m "full_model and (omni or tts) and H100" --run-level=full_model ``` + To run a standalone perf benchmark (defaults to ``test_qwen_omni.json``; use ``--test-config-file tests/dfx/perf/tests/test_tts.json`` for TTS): + + ```bash + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py + ``` + === "L5 level" L5 includes stability and reliability testing. Typical commands: diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py index 4c185066d..b0860978b 100644 --- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py +++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py @@ -36,7 +36,7 @@ import pytest from benchmarks.diffusion.backends import endpoint_filename_token, normalize_endpoint -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model] +pytestmark = [pytest.mark.diffusion, pytest.mark.full_model, pytest.mark.local_model] os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ.setdefault("DIFFUSION_ATTENTION_BACKEND", "FLASH_ATTN") diff --git a/tools/nightly/run_nightly_jobs.sh b/tools/nightly/run_nightly_jobs.sh index b115a9ee2..7134a36a1 100644 --- a/tools/nightly/run_nightly_jobs.sh +++ b/tools/nightly/run_nightly_jobs.sh @@ -9,7 +9,7 @@ # python tools/nightly/generate_nightly_perf_excel.py runs (even if some perf jobs failed; # the script only aggregates JSON already present under tests/dfx/perf/results). # Excel and generate_nightly_perf_excel.log are written under $REPO_ROOT/logs/; -# per-job tee logs use LOG_DIR (see --log-dir). Then remaining jobs run. +# per-job tee logs use LOG_DIR (see --log-dir; default is timestamped under $REPO_ROOT/logs/). # # Test kind (--test-type / TEST_TYPE), multiple allowed (OR semantics): # Repeat the flag and/or use comma-separated values, e.g. @@ -21,6 +21,8 @@ # function — label has neither "Perf Test" nor "Accuracy Test" (incl. Doc, Multi-Replica, etc.) # stability— fixed dfx stability scripts under tests/dfx/stability/scripts/ (see below) # local — pytest -sv -m "<MODEL_TYPE markers> and local_model" from repo root (no YAML step extract) +# When LABEL_SUBSTR is set, also runs matching tests/**/test_*.py and perf JSON configs under +# tests/dfx/perf/tests/*.json via run_benchmark.py / run_diffusion_benchmark.py. # all — no test-kind filter for YAML steps (any leaf step with pytest in test-nightly.yml) # # Model area (--model-type / MODEL_TYPE), multiple allowed (OR semantics): @@ -36,10 +38,12 @@ # local (when included in TEST_TYPE): # From repo root: pytest -sv -m "<markers> and local_model" (markers from MODEL_TYPE: omni, tts, # diffusion; all → "(omni or tts or diffusion) and local_model"). Not filtered by nightly YAML. +# LABEL_SUBSTR: if set, restrict to tests/**/test_*.py basenames and tests/dfx/perf/tests/*.json +# whose filename contains the substring (benchmark runner chosen by JSON family). # # stability (when included in TEST_TYPE): -# From repo root: pytest -s -v tests/dfx/stability/scripts/test_stability_*.py -# model_type: omni → qwen3_omni; tts → qwen3_tts; diffusion → qwen_image + wan22; all → all four +# From repo root: pytest -s -v --run-level full_model tests/dfx/stability/scripts/test_stability_*.py +# model_type: omni → qwen3_omni; tts → qwen3_tts + voxcpm2; diffusion → qwen_image + wan22 + hunyuan_image; all → all six # LABEL_SUBSTR: if set, script path / job key / filename must contain it # # Requirements: bash, python3, PyYAML (pip install pyyaml) @@ -58,11 +62,20 @@ # Optional environment: # REPO_ROOT - vllm-omni root (working directory for pytest); see above # YML - path to test-nightly.yml (default: $REPO_ROOT/.buildkite/test-nightly.yml) -# LOG_DIR - logs + generated job scripts (default: $REPO_ROOT/logs/nightly_jobs) +# LOG_DIR - logs + generated job scripts; when unset, a timestamped directory under +# $REPO_ROOT/logs/ is created: +# nightly_jobs_YYYYMMDD-HHMMSS (default / YAML nightly steps) +# nightly_local_jobs_YYYYMMDD-HHMMSS (--test-type local only) +# nightly_stability_jobs_YYYYMMDD-HHMMSS (--test-type stability only) +# per-job *.log plus timing_summary.log after the run; +# perf JSON from tests/dfx/perf/results/ (produced this run) -> $LOG_DIR/perf_results/ # TEST_TYPE - comma-separated and/or repeated flags (default: all); see above # MODEL_TYPE - comma-separated and/or repeated flags (default: all); see above -# LABEL_SUBSTR - YAML mode: substring of Buildkite step label; stability: substring of path/key/filename +# LABEL_SUBSTR - YAML mode: substring of Buildkite step label; stability: substring of path/key/filename; +# local: substring of test_*.py basename or tests/dfx/perf/tests/*.json filename # DRY_RUN=1 - print extracted commands only; do not write scripts or run pytest +# RUN_JOB_TIMEOUT_KILL_AFTER - seconds after SIGTERM before SIGKILL on inline pytest +# timeout (default: 60; baked into generated job scripts) # set -euo pipefail @@ -212,6 +225,28 @@ done TEST_TYPE="$(_finalize_test_type_csv)" MODEL_TYPE="$(_finalize_model_type_csv)" +# Default log directory basename: date + local timestamp; prefix depends on TEST_TYPE. +_nightly_log_dir_basename() { + local ts has_local=0 has_stability=0 has_yaml=0 _t + ts="$(date +%Y%m%d-%H%M%S)" + IFS=',' read -ra _TTOK <<< "${TEST_TYPE}" + for _t in "${_TTOK[@]}"; do + _t="$(printf '%s' "${_t}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + case "${_t}" in + local) has_local=1 ;; + stability) has_stability=1 ;; + perf | acc | function | all) has_yaml=1 ;; + esac + done + if [[ "${has_local}" -eq 1 && "${has_yaml}" -eq 0 && "${has_stability}" -eq 0 ]]; then + printf 'nightly_local_jobs_%s' "${ts}" + elif [[ "${has_stability}" -eq 1 && "${has_yaml}" -eq 0 && "${has_local}" -eq 0 ]]; then + printf 'nightly_stability_jobs_%s' "${ts}" + else + printf 'nightly_jobs_%s' "${ts}" + fi +} + BUILDKITE_REL=".buildkite/test-nightly.yml" _find_repo_containing_nightly() { @@ -269,7 +304,11 @@ else YML="${REPO_ROOT}/${BUILDKITE_REL}" fi -LOG_DIR="${LOG_DIR:-${REPO_ROOT}/logs/nightly_jobs}" +if [[ -z "${LOG_DIR:-}" ]]; then + LOG_DIR="${REPO_ROOT}/logs/$(_nightly_log_dir_basename)" +fi + +echo "Log directory: ${LOG_DIR}" >&2 # Require nightly YAML only when at least one non-stability test kind needs it (validated in Python too). _needs_nightly_yml=0 @@ -310,6 +349,7 @@ if [[ "${DRY_RUN}" != "1" ]]; then rm -f "${_stale_logs[@]}" fi rm -f "${LOG_DIR}/jobs/.perf_job_keys" + rm -f "${LOG_DIR}/jobs/.job_timeouts" rm -f "${REPO_ROOT}/logs/nightly_perf_manual.xlsx" rm -f "${LOG_DIR}/nightly_perf_manual.xlsx" fi @@ -320,6 +360,7 @@ from __future__ import annotations import os import re +import shlex import stat import sys from pathlib import Path @@ -491,12 +532,60 @@ def job_key_from_step(step: dict, label: str) -> str: return slug +def step_timeout_minutes(step: dict) -> int | None: + raw = step.get("timeout_in_minutes") + if raw is None: + return None + try: + minutes = int(raw) + except (TypeError, ValueError): + return None + return minutes if minutes > 0 else None + + +_INLINE_TIMEOUT_PREFIX = re.compile(r"^timeout\s+(?:-\S+\s+)*\S+\s+", re.IGNORECASE) + + +def timeout_kill_after_seconds() -> int: + raw = os.environ.get("RUN_JOB_TIMEOUT_KILL_AFTER", "60") + try: + seconds = int(raw) + except (TypeError, ValueError): + return 60 + return seconds if seconds > 0 else 60 + + +def prepend_timeout_to_pytest(pytest_line: str, timeout_min: int | None) -> str: + """Prepend GNU timeout directly before pytest (Buildkite inline style).""" + if timeout_min is None: + return pytest_line.strip() + line = _INLINE_TIMEOUT_PREFIX.sub("", pytest_line.strip()) + kill_after = timeout_kill_after_seconds() + return ( + f"timeout --foreground --verbose --kill-after={kill_after} " + f"{timeout_min}m {line}" + ) + + +def _write_job_timeouts_manifest(jobs_dir: Path, job_timeouts: dict[str, int]) -> None: + manifest_path = jobs_dir / ".job_timeouts" + if job_timeouts: + manifest_path.write_text( + "\n".join(f"{k}={v}" for k, v in sorted(job_timeouts.items())) + "\n", + encoding="utf-8", + ) + elif manifest_path.is_file(): + manifest_path.unlink() + + # Fixed stability scripts (when stability is selected); MODEL_TYPES narrows which run. STABILITY_CASES: list[tuple[str, str, tuple[str, ...]]] = [ ("stability_qwen3_omni", "tests/dfx/stability/scripts/test_stability_qwen3_omni.py", ("omni",)), ("stability_qwen3_tts", "tests/dfx/stability/scripts/test_stability_qwen3_tts.py", ("tts",)), + ("stability_voxcpm2", "tests/dfx/stability/scripts/test_stability_voxcpm2.py", ("tts",)), ("stability_qwen_image", "tests/dfx/stability/scripts/test_stability_qwen_image.py", ("diffusion",)), ("stability_wan22", "tests/dfx/stability/scripts/test_stability_wan22.py", ("diffusion",)), + ("stability_hunyuan_image", "tests/dfx/stability/scripts/test_stability_hunyuan_image.py", ("diffusion",)), ] @@ -533,14 +622,148 @@ def local_pytest_marker_expr(model_types: list[str]) -> str: return f"({' or '.join(families)}) and local_model" -def run_local_mode(jobs_dir: Path, model_types: list[str]) -> int: - """Single job: pytest -sv -m '<model markers> and local_model' (testpaths from pytest config).""" +def local_test_files_by_filename(substr: str) -> list[str]: + """Return repo-relative posix paths under tests/ whose basename contains substr.""" + tests_root = REPO_ROOT / "tests" + if not tests_root.is_dir(): + return [] + matches: list[str] = [] + for path in sorted(tests_root.rglob("test_*.py")): + if substr in path.name: + matches.append(path.relative_to(REPO_ROOT).as_posix()) + return matches + + +PERF_TESTS_REL = Path("tests/dfx/perf/tests") +RUN_BENCHMARK_REL = Path("tests/dfx/perf/scripts/run_benchmark.py") +RUN_DIFFUSION_BENCHMARK_REL = Path("tests/dfx/perf/scripts/run_diffusion_benchmark.py") +TTS_PERF_JSON_HINTS = ("test_tts", "voxcpm", "higgs_audio") + + +def perf_json_model_family(json_basename: str) -> str: + """Classify a perf JSON config as omni, tts, or diffusion (mirrors nightly YAML runners).""" + name = json_basename.lower() + if name.startswith("test_qwen3_omni"): + return "omni" + if any(hint in name for hint in TTS_PERF_JSON_HINTS): + return "tts" + return "diffusion" + + +def perf_json_runner(json_basename: str) -> Path: + if perf_json_model_family(json_basename) in ("omni", "tts"): + return RUN_BENCHMARK_REL + return RUN_DIFFUSION_BENCHMARK_REL + + +def perf_json_matches_model_type(json_basename: str, model_types: list[str]) -> bool: + if "all" in model_types: + return True + return perf_json_model_family(json_basename) in model_types + + +def local_perf_json_configs(substr: str, model_types: list[str]) -> list[tuple[str, str]]: + """Return (json_rel_posix, runner_rel_posix) for perf configs whose filename contains substr.""" + perf_dir = REPO_ROOT / PERF_TESTS_REL + if not perf_dir.is_dir(): + return [] + matches: list[tuple[str, str]] = [] + for path in sorted(perf_dir.glob("*.json")): + if substr not in path.name: + continue + if not perf_json_matches_model_type(path.name, model_types): + continue + runner = perf_json_runner(path.name) + if not (REPO_ROOT / runner).is_file(): + print(f"# skip (missing runner): {runner}", file=sys.stderr) + continue + matches.append( + ( + path.relative_to(REPO_ROOT).as_posix(), + runner.as_posix(), + ) + ) + return matches + + +def _local_job_slug(label: str) -> str: + slug = re.sub(r"[^\w\-.]+", "_", label, flags=re.UNICODE) + slug = re.sub(r"_+", "_", slug).strip("_") + return slug or "filter" + + +def run_local_mode( + jobs_dir: Path, + model_types: list[str], + perf_job_keys: list[str], +) -> int: + """pytest -sv -m '<model markers> and local_model'; optional LABEL_SUBSTR filters.""" marker_expr = local_pytest_marker_expr(model_types) - key = "local_pytest" + matched = 0 + + if LABEL_SUBSTR: + rel_paths = local_test_files_by_filename(LABEL_SUBSTR) + perf_pairs = local_perf_json_configs(LABEL_SUBSTR, model_types) + + if rel_paths: + paths_arg = " ".join(shlex.quote(p) for p in rel_paths) + pytest_line = f'pytest -sv -m "{marker_expr}" {paths_arg}' + slug = _local_job_slug(LABEL_SUBSTR) + key = f"local_pytest_{slug}" + header = ( + f"# Local marker tests — MODEL_TYPE={','.join(model_types)}, " + f"LABEL_SUBSTR={LABEL_SUBSTR!r} (test_*.py), files: {', '.join(rel_paths)}" + ) + script_lines = [ + "#!/usr/bin/env bash", + header, + "set -euo pipefail", + f'cd "{REPO_ROOT}"', + pytest_line, + ] + _write_job_script(key, script_lines, jobs_dir) + matched += 1 + + for json_rel, runner_rel in perf_pairs: + json_name = Path(json_rel).name + pytest_line = ( + f'pytest -s -v -m "{marker_expr}" ' + f"{shlex.quote(runner_rel)} " + f"--test-config-file {shlex.quote(json_rel)}" + ) + slug = _local_job_slug(Path(json_rel).stem) + key = f"local_perf_{slug}" + header = ( + f"# Local perf benchmark — MODEL_TYPE={','.join(model_types)}, " + f"config={json_name!r}, runner={Path(runner_rel).name}" + ) + script_lines = [ + "#!/usr/bin/env bash", + header, + "set -euo pipefail", + f'cd "{REPO_ROOT}"', + pytest_line, + ] + _write_job_script(key, script_lines, jobs_dir) + if key not in perf_job_keys: + perf_job_keys.append(key) + matched += 1 + + if matched == 0: + print( + f"# skip local: no tests/**/test_*.py or " + f"tests/dfx/perf/tests/*.json filename matches " + f"LABEL_SUBSTR={LABEL_SUBSTR!r}", + file=sys.stderr, + ) + return matched + pytest_line = f'pytest -sv -m "{marker_expr}"' + key = "local_pytest" + header = f"# Local marker tests — MODEL_TYPE={','.join(model_types)}" script_lines = [ "#!/usr/bin/env bash", - f"# Local marker tests — MODEL_TYPE={','.join(model_types)}", + header, "set -euo pipefail", f'cd "{REPO_ROOT}"', pytest_line, @@ -566,7 +789,7 @@ def run_stability_mode(jobs_dir: Path, model_types: list[str]) -> int: if not rel_path.is_file(): print(f"# skip (missing file): {rel_path}", file=sys.stderr) continue - pytest_line = f"pytest -s -v {rel_posix}" + pytest_line = f"pytest -s -v --run-level full_model {rel_posix}" matched += 1 script_lines = [ "#!/usr/bin/env bash", @@ -594,13 +817,15 @@ def main() -> None: matched_stability = 0 matched_yaml = 0 + matched_local = 0 perf_job_keys: list[str] = [] + job_timeouts: dict[str, int] = {} if want_stability: matched_stability = run_stability_mode(jobs_dir, model_types) if want_local: - run_local_mode(jobs_dir, model_types) + matched_local = run_local_mode(jobs_dir, model_types, perf_job_keys) if needs_yaml: if not YML.is_file(): @@ -634,8 +859,12 @@ def main() -> None: "set -euo pipefail", f'cd "{REPO_ROOT}"', ] + timeout_min = step_timeout_minutes(step) + if timeout_min is not None: + script_lines.append(f"# Buildkite timeout_in_minutes: {timeout_min}") + job_timeouts[key] = timeout_min script_lines.extend(exports) - script_lines.extend(pys) + script_lines.extend(prepend_timeout_to_pytest(p, timeout_min) for p in pys) _write_job_script(key, script_lines, jobs_dir) if "Perf Test" in label and key not in perf_job_keys: perf_job_keys.append(key) @@ -646,6 +875,7 @@ def main() -> None: manifest_path.write_text("\n".join(perf_job_keys) + "\n", encoding="utf-8") elif manifest_path.is_file(): manifest_path.unlink() + _write_job_timeouts_manifest(jobs_dir, job_timeouts) errs: list[str] = [] if want_stability and matched_stability == 0: @@ -653,6 +883,17 @@ def main() -> None: f"No stability jobs matched TEST_TYPE={test_types!r} MODEL_TYPE={model_types!r} " f"LABEL_SUBSTR={LABEL_SUBSTR!r} under {REPO_ROOT}" ) + if want_local and matched_local == 0: + if LABEL_SUBSTR: + errs.append( + f"No local jobs matched LABEL_SUBSTR={LABEL_SUBSTR!r} " + f"(tests/**/test_*.py or tests/dfx/perf/tests/*.json) " + f"under {REPO_ROOT}" + ) + else: + errs.append( + f"Local mode produced no jobs TEST_TYPE={test_types!r} MODEL_TYPE={model_types!r}" + ) if needs_yaml and matched_yaml == 0: errs.append( f"No YAML steps matched TEST_TYPE={test_types!r} MODEL_TYPE={model_types!r} " @@ -671,29 +912,70 @@ if [[ "${DRY_RUN}" == "1" ]]; then exit 0 fi +# shellcheck source=tools/run_jobs_common.sh +source "${SCRIPT_DIR}/../run_jobs_common.sh" + +PERF_RESULTS_SRC="${REPO_ROOT}/tests/dfx/perf/results" + +# Copy perf benchmark JSON written under tests/dfx/perf/results/ during this run into LOG_DIR. +_collect_perf_result_jsons() { + local since_epoch="${1:?}" + local dest="${LOG_DIR}/perf_results" + + if [[ ! -d "${PERF_RESULTS_SRC}" ]]; then + return 0 + fi + + COLLECT_SINCE_EPOCH="${since_epoch}" \ + python3 - <<'PY' +import os +import shutil +import sys +from pathlib import Path + +repo = Path(os.environ["REPO_ROOT"]).resolve() +src = repo / "tests" / "dfx" / "perf" / "results" +dest = Path(os.environ["LOG_DIR"]).resolve() / "perf_results" +since = float(os.environ["COLLECT_SINCE_EPOCH"]) + +if not src.is_dir(): + sys.exit(0) + +copied = 0 +for path in sorted(src.rglob("*.json")): + if not path.is_file(): + continue + try: + if path.stat().st_mtime < since: + continue + except OSError: + continue + rel = path.relative_to(src) + out = dest / rel + out.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, out) + copied += 1 + +if copied: + print(f"Collected {copied} perf result JSON file(s) -> {dest}", file=sys.stderr) +PY +} + # Run jobs: YAML perf steps first (if manifest exists), then generate Excel (always when # manifest non-empty, even if some perf jobs failed; Excel uses whatever JSON exists on disk), # then remaining jobs. Paths align with nightly Buildkite (tests/dfx/perf/results). run_generated_jobs_with_tee() { set -o pipefail local any_fail=0 + local _run_start local perf_list="${LOG_DIR}/jobs/.perf_job_keys" local -a perf_keys=() + _run_jobs_reset_timing + _run_start="$(_run_jobs_epoch_seconds)" if [[ -f "${perf_list}" ]] && [[ -s "${perf_list}" ]]; then mapfile -t perf_keys < "${perf_list}" fi - _run_one_job() { - local _job="$1" - local base out job_status - base="$(basename "${_job}" .sh)" - out="${LOG_DIR}/${base}.log" - echo "==> ${_job} (tee ${out})" >&2 - (cd "${REPO_ROOT}" && bash "${_job}") 2>&1 | tee "${out}" - job_status="${PIPESTATUS[0]}" - return "${job_status}" - } - local k for k in "${perf_keys[@]}"; do [[ -n "${k}" ]] || continue @@ -702,11 +984,11 @@ run_generated_jobs_with_tee() { any_fail=1 continue fi - _run_one_job "${LOG_DIR}/jobs/${k}.sh" || any_fail=1 + _run_one_job_with_timing "${LOG_DIR}/jobs/${k}.sh" || any_fail=1 done if ((${#perf_keys[@]})); then - local excel_out excel_log excel_status excel_repo_logs + local excel_out excel_log excel_status excel_repo_logs excel_start excel_end excel_elapsed excel_repo_logs="${REPO_ROOT}/logs" mkdir -p "${excel_repo_logs}" excel_out="${excel_repo_logs}/nightly_perf_$(date -u +%Y%m%d-%H%M%S).xlsx" @@ -716,6 +998,7 @@ run_generated_jobs_with_tee() { "(report reflects JSON already under tests/dfx/perf/results)." >&2 fi echo "==> python3 tools/nightly/generate_nightly_perf_excel.py -> ${excel_out} (tee ${excel_log})" >&2 + excel_start="$(_run_jobs_epoch_seconds)" ( cd "${REPO_ROOT}" && python3 tools/nightly/generate_nightly_perf_excel.py \ --input-dir "${REPO_ROOT}/tests/dfx/perf/results" \ @@ -723,7 +1006,13 @@ run_generated_jobs_with_tee() { --output-file "${excel_out}" ) 2>&1 | tee "${excel_log}" excel_status="${PIPESTATUS[0]}" - if [[ "${excel_status}" -ne 0 ]]; then + excel_end="$(_run_jobs_epoch_seconds)" + excel_elapsed=$((excel_end - excel_start)) + _run_jobs_record_timing "generate_nightly_perf_excel" "${excel_elapsed}" "${excel_status}" + if [[ "${excel_status}" -eq 0 ]]; then + echo " finished in $(_run_jobs_format_duration "${excel_elapsed}")" >&2 + else + echo " failed after $(_run_jobs_format_duration "${excel_elapsed}") (exit ${excel_status})" >&2 any_fail=1 echo "generate_nightly_perf_excel.py failed (exit ${excel_status}). See ${excel_log}" >&2 fi @@ -739,15 +1028,16 @@ run_generated_jobs_with_tee() { for _job in "${LOG_DIR}/jobs"/*.sh; do base="$(basename "${_job}" .sh)" [[ ${_perf_done["${base}"]+isset} ]] && continue - _run_one_job "${_job}" || any_fail=1 + _run_one_job_with_timing "${_job}" || any_fail=1 done shopt -u nullglob + _collect_perf_result_jsons "${_run_start}" + + _run_jobs_print_timing_summary "${_run_start}" "${any_fail}" if [[ "${any_fail}" -ne 0 ]]; then - echo "One or more selected jobs failed. See logs under ${LOG_DIR}." >&2 return 1 fi - echo "All selected jobs finished OK. Logs: ${LOG_DIR}/*.log" >&2 return 0 } diff --git a/tools/run_jobs_common.sh b/tools/run_jobs_common.sh new file mode 100644 index 000000000..ddc1794dc --- /dev/null +++ b/tools/run_jobs_common.sh @@ -0,0 +1,653 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# Shared helpers for tools/run_*_jobs.sh (source this file; do not execute). +# +# Contents: +# 1) Timing / tee runners used by ready, merge, and nightly. +# 2) Ready/merge CLI + YAML extract entrypoint: run_yaml_ci_jobs_main +# (set BUILDKITE_REL and DEFAULT_LOG_SUBDIR in the entry script, then call it). +# +# Job timeouts: generated job scripts embed GNU timeout on the pytest line itself +# (see prepend_timeout_to_pytest in the Python heredoc below). This runner only +# executes bash job.sh and records exit 124 as TIMED OUT via jobs/.job_timeouts +# metadata. + +if [[ -n "${_RUN_JOBS_COMMON_LOADED:-}" ]]; then + return 0 2>/dev/null || exit 0 +fi +_RUN_JOBS_COMMON_LOADED=1 + +if [[ -z "${SCRIPT_DIR:-}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fi + +# --------------------------------------------------------------------------- +# Timing / tee helpers (ready, merge, nightly) +# --------------------------------------------------------------------------- + +RUN_JOB_NAMES=() +RUN_JOB_SECONDS=() +RUN_JOB_STATUSES=() + +_run_jobs_reset_timing() { + RUN_JOB_NAMES=() + RUN_JOB_SECONDS=() + RUN_JOB_STATUSES=() +} + +_run_jobs_epoch_seconds() { + date +%s +} + +_run_jobs_format_duration() { + local total="${1}" + local h m s + if [[ "${total}" -lt 60 ]]; then + printf '%ss' "${total}" + return 0 + fi + m=$((total / 60)) + s=$((total % 60)) + if [[ "${m}" -lt 60 ]]; then + if [[ "${s}" -eq 0 ]]; then + printf '%dm' "${m}" + else + printf '%dm %ss' "${m}" "${s}" + fi + return 0 + fi + h=$((m / 60)) + m=$((m % 60)) + if [[ "${m}" -eq 0 && "${s}" -eq 0 ]]; then + printf '%dh' "${h}" + elif [[ "${s}" -eq 0 ]]; then + printf '%dh %dm' "${h}" "${m}" + else + printf '%dh %dm %ss' "${h}" "${m}" "${s}" + fi +} + +_run_jobs_record_timing() { + local name="${1}" + local seconds="${2}" + local status="${3}" + RUN_JOB_NAMES+=("${name}") + RUN_JOB_SECONDS+=("${seconds}") + RUN_JOB_STATUSES+=("${status}") +} + +_run_jobs_lookup_timeout_minutes() { + local base="${1}" + local manifest="${LOG_DIR}/jobs/.job_timeouts" + local line key mins + [[ -f "${manifest}" ]] || return 1 + while IFS='=' read -r key mins; do + [[ -n "${key}" ]] || continue + if [[ "${key}" == "${base}" ]]; then + printf '%s' "${mins}" + return 0 + fi + done < "${manifest}" + return 1 +} + +_run_one_job_with_timing() { + local _job="$1" + local base out job_status start end elapsed timeout_min="" + base="$(basename "${_job}" .sh)" + out="${LOG_DIR}/${base}.log" + if timeout_min="$(_run_jobs_lookup_timeout_minutes "${base}")"; then + echo "==> ${_job} (tee ${out}, pytest inline timeout ${timeout_min}m)" >&2 + else + echo "==> ${_job} (tee ${out})" >&2 + fi + start="$(_run_jobs_epoch_seconds)" + (cd "${REPO_ROOT}" && bash "${_job}") 2>&1 | tee "${out}" + job_status="${PIPESTATUS[0]}" + end="$(_run_jobs_epoch_seconds)" + elapsed=$((end - start)) + _run_jobs_record_timing "${base}" "${elapsed}" "${job_status}" + if [[ "${job_status}" -eq 0 ]]; then + echo " finished in $(_run_jobs_format_duration "${elapsed}")" >&2 + elif [[ "${job_status}" -eq 124 && -n "${timeout_min}" ]]; then + echo " timed out (inline timeout ${timeout_min}m on pytest, exit 124)" >&2 + elif [[ "${job_status}" -eq 124 ]]; then + echo " timed out (exit 124)" >&2 + else + echo " failed after $(_run_jobs_format_duration "${elapsed}") (exit ${job_status})" >&2 + fi + return "${job_status}" +} + +_run_jobs_print_timing_summary() { + local wall_start="${1:-}" + local any_fail="${2:-0}" + local summary_path="${LOG_DIR}/timing_summary.log" + local -a lines=() + local i name secs status status_str failed_count=0 + local wall_elapsed job_count total_elapsed=0 + + lines+=("=== Job timing summary ===") + for i in "${!RUN_JOB_NAMES[@]}"; do + name="${RUN_JOB_NAMES[$i]}" + secs="${RUN_JOB_SECONDS[$i]}" + status="${RUN_JOB_STATUSES[$i]}" + if [[ "${status}" -eq 0 ]]; then + status_str="OK" + elif [[ "${status}" -eq 124 ]]; then + status_str="TIMED OUT" + failed_count=$((failed_count + 1)) + else + status_str="FAILED (exit ${status})" + failed_count=$((failed_count + 1)) + fi + lines+=(" ${name} $(_run_jobs_format_duration "${secs}") ${status_str}") + done + + job_count="${#RUN_JOB_NAMES[@]}" + if [[ -n "${wall_start}" ]]; then + wall_elapsed=$(( $(_run_jobs_epoch_seconds) - wall_start )) + lines+=("Total wall time: $(_run_jobs_format_duration "${wall_elapsed}") (${job_count} jobs)") + elif [[ "${job_count}" -gt 0 ]]; then + for secs in "${RUN_JOB_SECONDS[@]}"; do + total_elapsed=$((total_elapsed + secs)) + done + lines+=("Total job time: $(_run_jobs_format_duration "${total_elapsed}") (${job_count} jobs)") + else + lines+=("Total wall time: 0s (0 jobs)") + fi + + if [[ "${failed_count}" -gt 0 ]]; then + lines+=("Failed jobs: ${failed_count}/${job_count}") + fi + + if [[ "${any_fail}" -ne 0 ]]; then + lines+=("Result: one or more jobs failed. See logs under ${LOG_DIR}.") + else + lines+=("Result: all jobs finished OK. Logs: ${LOG_DIR}/*.log") + fi + + { + for line in "${lines[@]}"; do + printf '%s\n' "${line}" + done + } | tee "${summary_path}" >&2 +} + +# --------------------------------------------------------------------------- +# Ready / merge: CLI + YAML extract + run (tools/run_ready_jobs.sh, +# tools/run_merge_jobs.sh). Nightly does not use this entrypoint. +# +# Entry scripts must set before calling run_yaml_ci_jobs_main: +# BUILDKITE_REL - e.g. .buildkite/test-ready.yml +# DEFAULT_LOG_SUBDIR - e.g. ready_jobs +# --------------------------------------------------------------------------- + +_run_yaml_ci_jobs_usage() { + # Print the entry script header (caller is run_ready_jobs.sh / run_merge_jobs.sh). + sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//' +} + +_run_jobs_split_append_csv_array() { + local -n _arr="${1}" + local _raw="${2}" + local _ifs="${IFS}" + local _part + IFS=',' + for _part in ${_raw}; do + IFS="${_ifs}" + _part="$(printf '%s' "${_part}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + [[ -n "${_part}" ]] || continue + _arr+=("${_part}") + done +} + +_run_jobs_finalize_model_type_csv() { + if [[ "${MODEL_TYPE_FROM_CLI}" -eq 1 ]]; then + if ((${#MODEL_TYPE_CLI_PARTS[@]} == 0)); then + echo "--model-type requires a non-empty value" >&2 + exit 2 + fi + local -A _seen=() + local -a _out=() + local _x + for _x in "${MODEL_TYPE_CLI_PARTS[@]}"; do + if [[ "${_x}" == all ]]; then + printf '%s' "all" + return 0 + fi + [[ ${_seen["${_x}"]+isset} ]] && continue + _seen["${_x}"]=1 + _out+=("${_x}") + done + (IFS=','; printf '%s' "${_out[*]}") + else + printf '%s' "${MODEL_TYPE_ENV}" + fi +} + +_run_jobs_find_repo_containing_yml() { + local dir="${1:-}" + [[ -n "$dir" ]] || return 1 + dir="$(cd "$dir" && pwd)" || return 1 + while true; do + if [[ -f "${dir}/${BUILDKITE_REL}" ]]; then + printf '%s\n' "$dir" + return 0 + fi + [[ "${dir}" == "/" ]] && return 1 + dir="$(dirname "${dir}")" + done +} + +_run_jobs_derive_repo_root_from_yml() { + local yml="$1" + local d + d="$(cd "$(dirname "${yml}")" && pwd)" + [[ "$(basename "${d}")" == ".buildkite" ]] || return 1 + printf '%s\n' "$(dirname "${d}")" +} + +run_yaml_ci_jobs_main() { + if [[ -z "${BUILDKITE_REL:-}" || -z "${DEFAULT_LOG_SUBDIR:-}" ]]; then + echo "run_yaml_ci_jobs_main: set BUILDKITE_REL and DEFAULT_LOG_SUBDIR before calling." >&2 + exit 1 + fi + + LABEL_SUBSTR="${LABEL_SUBSTR:-}" + MODEL_TYPE_ENV="${MODEL_TYPE:-all}" + MODEL_TYPE_CLI_PARTS=() + MODEL_TYPE_FROM_CLI=0 + DRY_RUN="${DRY_RUN:-0}" + SKIP_SIMPLE=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + _run_yaml_ci_jobs_usage + exit 0 + ;; + --dry-run) + DRY_RUN=1 + shift + ;; + --log-dir) + LOG_DIR="$2" + shift 2 + ;; + --yaml) + YML="$2" + shift 2 + ;; + --repo-root) + REPO_ROOT="$2" + shift 2 + ;; + --label-substr) + LABEL_SUBSTR="$2" + shift 2 + ;; + --model-type) + MODEL_TYPE_FROM_CLI=1 + _run_jobs_split_append_csv_array MODEL_TYPE_CLI_PARTS "$2" + shift 2 + ;; + --skip-simple) + SKIP_SIMPLE=1 + shift + ;; + *) + echo "Unknown option: $1" >&2 + _run_yaml_ci_jobs_usage >&2 + exit 2 + ;; + esac + done + + MODEL_TYPE="$(_run_jobs_finalize_model_type_csv)" + + if [[ -n "${YML:-}" && -n "${REPO_ROOT:-}" ]]; then + REPO_ROOT="$(cd "${REPO_ROOT}" && pwd)" + YML="$(cd "$(dirname "${YML}")" && pwd)/$(basename "${YML}")" + elif [[ -n "${YML:-}" ]]; then + YML="$(cd "$(dirname "${YML}")" && pwd)/$(basename "${YML}")" + if ! REPO_ROOT="$(_run_jobs_derive_repo_root_from_yml "${YML}")"; then + echo "Could not derive REPO_ROOT from YML=${YML} (expected file at <repo>/${BUILDKITE_REL})." >&2 + echo "Set REPO_ROOT explicitly (or pass --repo-root) for pytest working directory." >&2 + exit 2 + fi + elif [[ -n "${REPO_ROOT:-}" ]]; then + REPO_ROOT="$(cd "${REPO_ROOT}" && pwd)" + YML="${REPO_ROOT}/${BUILDKITE_REL}" + else + REPO_ROOT="" + if command -v git >/dev/null 2>&1; then + REPO_ROOT="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null || true)" + fi + if [[ -z "${REPO_ROOT}" ]]; then + REPO_ROOT="$(_run_jobs_find_repo_containing_yml "${PWD}" || true)" + fi + if [[ -z "${REPO_ROOT}" ]]; then + REPO_ROOT="$(_run_jobs_find_repo_containing_yml "${SCRIPT_DIR}" || true)" + fi + if [[ -z "${REPO_ROOT}" ]]; then + echo "Could not locate ${BUILDKITE_REL}. Set REPO_ROOT or YML, run from inside the vllm-omni clone," >&2 + echo "or place this script (or run from a cwd) under the repository tree." >&2 + exit 2 + fi + YML="${REPO_ROOT}/${BUILDKITE_REL}" + fi + + LOG_DIR="${LOG_DIR:-${REPO_ROOT}/logs/${DEFAULT_LOG_SUBDIR}}" + + if [[ ! -f "${YML}" ]]; then + echo "YAML not found: ${YML}" >&2 + exit 2 + fi + + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required." >&2 + exit 1 + fi + + mkdir -p "${LOG_DIR}/jobs" + LOG_DIR="$(cd "${LOG_DIR}" && pwd)" + export REPO_ROOT LOG_DIR YML LABEL_SUBSTR MODEL_TYPE DRY_RUN SKIP_SIMPLE + + if [[ "${DRY_RUN}" != "1" ]]; then + shopt -s nullglob + _stale=( "${LOG_DIR}/jobs"/*.sh ) + _stale_logs=( "${LOG_DIR}"/*.log ) + shopt -u nullglob + if ((${#_stale[@]})); then + rm -f "${_stale[@]}" + fi + if ((${#_stale_logs[@]})); then + rm -f "${_stale_logs[@]}" + fi + rm -f "${LOG_DIR}/jobs/.job_timeouts" + fi + + # shellcheck disable=SC2016,SC1078,SC1079 + python3 - <<'PY' +from __future__ import annotations + +import os +import re +import stat +import sys +from pathlib import Path + +try: + import yaml +except ImportError: + print("Missing PyYAML. Install with: pip install pyyaml", file=sys.stderr) + sys.exit(1) + +REPO_ROOT = Path(os.environ["REPO_ROOT"]).resolve() +LOG_DIR = Path(os.environ["LOG_DIR"]).resolve() +YML = Path(os.environ["YML"]).resolve() +LABEL_SUBSTR = (os.environ.get("LABEL_SUBSTR") or "").strip() +DRY_RUN = os.environ.get("DRY_RUN", "0") == "1" +SKIP_SIMPLE = os.environ.get("SKIP_SIMPLE", "0") == "1" + +ALLOWED_MODEL_TYPES = frozenset({"all", "omni", "tts", "diffusion"}) + + +def parse_model_types(raw: str) -> list[str]: + parts = [p.strip().lower() for p in (raw or "").split(",") if p.strip()] + if not parts: + parts = ["all"] + bad = [p for p in parts if p not in ALLOWED_MODEL_TYPES] + if bad: + print( + f"Invalid MODEL_TYPE / --model-type value(s): {bad!r} " + f"(allowed: {', '.join(sorted(ALLOWED_MODEL_TYPES))})", + file=sys.stderr, + ) + sys.exit(2) + if "all" in parts: + return ["all"] + out: list[str] = [] + seen: set[str] = set() + for p in parts: + if p not in seen: + seen.add(p) + out.append(p) + return out + + +PYTEST_CMD_RE = re.compile( + r"(?:timeout\s+\S+\s+)?(?:python3? -m\s+)?pytest\s+[^\n&|;]*" +) + + +def iter_leaf_steps(steps, group: str | None = None): + for raw in steps or []: + if not isinstance(raw, dict): + continue + nested = raw.get("steps") + if isinstance(nested, list) and nested: + g = raw.get("group") + next_group: str | None + if isinstance(g, str): + next_group = g + elif g is not None: + next_group = str(g) + else: + next_group = group + yield from iter_leaf_steps(nested, next_group) + continue + if raw.get("commands"): + yield raw, group + + +def raw_command_text(step: dict) -> str: + raw = step.get("commands") or [] + if isinstance(raw, str): + raw = [raw] + text = "\n".join((c.strip() if isinstance(c, str) else "") for c in raw if c) + return text.replace("$$", "$") + + +def export_lines(text: str) -> list[str]: + out: list[str] = [] + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("#"): + continue + if s.startswith("export "): + out.append(s) + return out + + +def pytest_lines(text: str) -> list[str]: + out: list[str] = [] + for m in PYTEST_CMD_RE.finditer(text): + line = m.group(0).strip() + line_start = text.rfind("\n", 0, m.start()) + 1 + before = text[line_start : m.start()] + if before.lstrip().startswith("#"): + continue + if line: + out.append(line) + return out + + +def is_simple_test_step(label: str, group: str | None) -> bool: + if group and "Simple Test" in group: + return True + return label.startswith("Simple ·") + + +def label_matches_model_type(label: str, model_types: list[str]) -> bool: + if "all" in model_types: + return True + if "omni" in model_types and "Omni ·" in label: + return True + if "tts" in model_types and "TTS ·" in label: + return True + if "diffusion" in model_types and "Diffusion" in label: + return True + return False + + +def job_key_from_step(step: dict, label: str) -> str: + k = step.get("key") + if isinstance(k, str) and k.strip(): + return k.strip() + slug = re.sub(r"[^\w\-.]+", "_", label or "job", flags=re.UNICODE) + slug = re.sub(r"_+", "_", slug).strip("_") or "job" + return slug + + +def step_timeout_minutes(step: dict) -> int | None: + raw = step.get("timeout_in_minutes") + if raw is None: + return None + try: + minutes = int(raw) + except (TypeError, ValueError): + return None + return minutes if minutes > 0 else None + + +_INLINE_TIMEOUT_PREFIX = re.compile(r"^timeout\s+(?:-\S+\s+)*\S+\s+", re.IGNORECASE) + + +def timeout_kill_after_seconds() -> int: + raw = os.environ.get("RUN_JOB_TIMEOUT_KILL_AFTER", "60") + try: + seconds = int(raw) + except (TypeError, ValueError): + return 60 + return seconds if seconds > 0 else 60 + + +def prepend_timeout_to_pytest(pytest_line: str, timeout_min: int | None) -> str: + """Prepend GNU timeout directly before pytest (Buildkite inline style).""" + if timeout_min is None: + return pytest_line.strip() + line = _INLINE_TIMEOUT_PREFIX.sub("", pytest_line.strip()) + kill_after = timeout_kill_after_seconds() + return ( + f"timeout --foreground --verbose --kill-after={kill_after} " + f"{timeout_min}m {line}" + ) + + +def _write_job_timeouts_manifest(jobs_dir: Path, job_timeouts: dict[str, int]) -> None: + manifest_path = jobs_dir / ".job_timeouts" + if job_timeouts: + manifest_path.write_text( + "\n".join(f"{k}={v}" for k, v in sorted(job_timeouts.items())) + "\n", + encoding="utf-8", + ) + elif manifest_path.is_file(): + manifest_path.unlink() + + +def _write_job_script(key: str, script_lines: list[str], jobs_dir: Path) -> None: + body = "\n".join(script_lines) + "\n" + if DRY_RUN: + print(f"=== {key} ===") + print(body) + return + job_path = jobs_dir / f"{key}.sh" + job_path.write_text(body, encoding="utf-8") + try: + mode = job_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + job_path.chmod(mode) + except OSError: + pass + print(f"generated {job_path}", file=sys.stderr) + + +def main() -> None: + model_types = parse_model_types(os.environ.get("MODEL_TYPE", "all")) + + jobs_dir = LOG_DIR / "jobs" + if not DRY_RUN: + jobs_dir.mkdir(parents=True, exist_ok=True) + + if not YML.is_file(): + print(f"YAML not found: {YML}", file=sys.stderr) + sys.exit(1) + + data = yaml.safe_load(YML.read_text(encoding="utf-8")) + top_steps = (data or {}).get("steps") or [] + + matched_yaml = 0 + job_timeouts: dict[str, int] = {} + for step, grp in iter_leaf_steps(top_steps): + label = step.get("label") or "" + if SKIP_SIMPLE and is_simple_test_step(label, grp): + continue + if LABEL_SUBSTR and LABEL_SUBSTR not in label: + continue + if not label_matches_model_type(label, model_types): + continue + text = raw_command_text(step) + exports = export_lines(text) + pys = pytest_lines(text) + if not pys: + print(f"# skip (no pytest line): {label!r}", file=sys.stderr) + continue + + key = job_key_from_step(step, label) + matched_yaml += 1 + + script_lines = [ + "#!/usr/bin/env bash", + f'# From Buildkite label: {label.replace(chr(10), " ")}', + "set -euo pipefail", + f'cd "{REPO_ROOT}"', + ] + timeout_min = step_timeout_minutes(step) + if timeout_min is not None: + script_lines.append(f"# Buildkite timeout_in_minutes: {timeout_min}") + job_timeouts[key] = timeout_min + script_lines.extend(exports) + script_lines.extend(prepend_timeout_to_pytest(p, timeout_min) for p in pys) + _write_job_script(key, script_lines, jobs_dir) + + if not DRY_RUN: + _write_job_timeouts_manifest(jobs_dir, job_timeouts) + + if matched_yaml == 0: + print( + f"No YAML steps matched MODEL_TYPE={model_types!r} " + f"LABEL_SUBSTR={LABEL_SUBSTR!r} SKIP_SIMPLE={SKIP_SIMPLE!r} in {YML}", + file=sys.stderr, + ) + sys.exit(2) + + +main() +PY + + if [[ "${DRY_RUN}" == "1" ]]; then + exit 0 + fi + + set -o pipefail + local any_fail=0 + local _run_start + local _job + _run_jobs_reset_timing + _run_start="$(_run_jobs_epoch_seconds)" + + shopt -s nullglob + for _job in "${LOG_DIR}/jobs"/*.sh; do + _run_one_job_with_timing "${_job}" || any_fail=1 + done + shopt -u nullglob + + _run_jobs_print_timing_summary "${_run_start}" "${any_fail}" + if [[ "${any_fail}" -ne 0 ]]; then + exit 1 + fi + exit 0 +} diff --git a/tools/run_merge_jobs.sh b/tools/run_merge_jobs.sh new file mode 100644 index 000000000..35b38a8e0 --- /dev/null +++ b/tools/run_merge_jobs.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# Extract steps from .buildkite/test-merge.yml that contain pytest, synthesize +# small bash wrappers (exports + pytest), run them, and tee output to logs named +# after each step's Buildkite "key" when present (otherwise a slug of the label). +# +# Model area (--model-type / MODEL_TYPE), multiple allowed (OR semantics): +# e.g. --model-type omni,tts +# omni — label contains "Omni ·" +# tts — label contains "TTS ·" +# diffusion— label contains "Diffusion" +# all — no model filter (default) +# +# --skip-simple — skip steps in the "Simple Test" Buildkite group and labels +# starting with "Simple ·" (L1-style unit tests in the same YAML) +# +# Requirements: bash, python3, PyYAML (pip install pyyaml) +# +# Usage: +# bash tools/run_merge_jobs.sh +# REPO_ROOT=/path/to/vllm-omni bash tools/run_merge_jobs.sh --model-type diffusion --dry-run +# YML=/path/to/vllm-omni/.buildkite/test-merge.yml bash tools/run_merge_jobs.sh +# +# Repository / YAML (no dependency on where this script lives): +# • Set REPO_ROOT (or pass --repo-root) — default YAML is $REPO_ROOT/.buildkite/test-merge.yml +# • Or set YML (or --yaml) — repo root is inferred as parent of the .buildkite directory +# • Or run from inside the clone: git rev-parse --show-toplevel, else walk up from $PWD, +# then from the script's directory, until .buildkite/test-merge.yml exists +# +# Optional environment: +# REPO_ROOT - vllm-omni root (working directory for pytest); see above +# YML - path to test-merge.yml (default: $REPO_ROOT/.buildkite/test-merge.yml) +# LOG_DIR - logs + generated job scripts (default: $REPO_ROOT/logs/merge_jobs); +# per-job *.log plus timing_summary.log after the run +# MODEL_TYPE - comma-separated and/or repeated flags (default: all); see above +# LABEL_SUBSTR - substring of Buildkite step label +# DRY_RUN=1 - print extracted commands only; do not write scripts or run pytest +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILDKITE_REL=".buildkite/test-merge.yml" +DEFAULT_LOG_SUBDIR="merge_jobs" + +# shellcheck source=tools/run_jobs_common.sh +source "${SCRIPT_DIR}/run_jobs_common.sh" +run_yaml_ci_jobs_main "$@" diff --git a/tools/run_ready_jobs.sh b/tools/run_ready_jobs.sh new file mode 100644 index 000000000..89f082436 --- /dev/null +++ b/tools/run_ready_jobs.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# +# Extract steps from .buildkite/test-ready.yml that contain pytest, synthesize +# small bash wrappers (exports + pytest), run them, and tee output to logs named +# after each step's Buildkite "key" when present (otherwise a slug of the label). +# +# Model area (--model-type / MODEL_TYPE), multiple allowed (OR semantics): +# e.g. --model-type omni,tts +# omni — label contains "Omni ·" +# tts — label contains "TTS ·" +# diffusion— label contains "Diffusion" +# all — no model filter (default) +# +# --skip-simple — skip steps in the "Simple Test" Buildkite group and labels +# starting with "Simple ·" (L1-style unit tests in the same YAML) +# +# Requirements: bash, python3, PyYAML (pip install pyyaml) +# +# Usage: +# bash tools/run_ready_jobs.sh +# REPO_ROOT=/path/to/vllm-omni bash tools/run_ready_jobs.sh --model-type omni --dry-run +# YML=/path/to/vllm-omni/.buildkite/test-ready.yml bash tools/run_ready_jobs.sh +# +# Repository / YAML (no dependency on where this script lives): +# • Set REPO_ROOT (or pass --repo-root) — default YAML is $REPO_ROOT/.buildkite/test-ready.yml +# • Or set YML (or --yaml) — repo root is inferred as parent of the .buildkite directory +# • Or run from inside the clone: git rev-parse --show-toplevel, else walk up from $PWD, +# then from the script's directory, until .buildkite/test-ready.yml exists +# +# Optional environment: +# REPO_ROOT - vllm-omni root (working directory for pytest); see above +# YML - path to test-ready.yml (default: $REPO_ROOT/.buildkite/test-ready.yml) +# LOG_DIR - logs + generated job scripts (default: $REPO_ROOT/logs/ready_jobs); +# per-job *.log plus timing_summary.log after the run +# MODEL_TYPE - comma-separated and/or repeated flags (default: all); see above +# LABEL_SUBSTR - substring of Buildkite step label +# DRY_RUN=1 - print extracted commands only; do not write scripts or run pytest +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILDKITE_REL=".buildkite/test-ready.yml" +DEFAULT_LOG_SUBDIR="ready_jobs" + +# shellcheck source=tools/run_jobs_common.sh +source "${SCRIPT_DIR}/run_jobs_common.sh" +run_yaml_ci_jobs_main "$@" From a432b8a325846a63c87f23b6e5731a67dd44771b Mon Sep 17 00:00:00 2001 From: Shi Boao Bowie <aoibosh@connect.hku.hk> Date: Wed, 15 Jul 2026 15:50:19 +0800 Subject: [PATCH 090/101] [Refactor][OutputProcessor 1/8]: clean up multimodal payload accumulation and move payload logic into MultimodalPayload & directory refactor (#4980) Signed-off-by: Boao Shi <aoibosh@connect.hku.hk> Signed-off-by: bjf-frz <frz123db@gmail.com> Co-authored-by: bjf-frz <frz123db@gmail.com> --- .gitignore | 1 + docs/api/README.md | 12 +- tests/engine/test_output_modality.py | 14 +- tests/engine/test_output_processor.py | 4 +- vllm_omni/engine/arg_utils.py | 2 +- vllm_omni/engine/mm_outputs.py | 132 +---- vllm_omni/engine/output_modality.py | 125 +---- vllm_omni/engine/output_processor.py | 768 +------------------------- vllm_omni/engine/stage_init_utils.py | 2 +- vllm_omni/outputs/mm_outputs.py | 261 +++++++++ vllm_omni/outputs/output_modality.py | 120 ++++ vllm_omni/outputs/output_processor.py | 633 +++++++++++++++++++++ vllm_omni/outputs/utils.py | 24 + 13 files changed, 1080 insertions(+), 1018 deletions(-) create mode 100644 vllm_omni/outputs/mm_outputs.py create mode 100644 vllm_omni/outputs/output_modality.py create mode 100644 vllm_omni/outputs/output_processor.py create mode 100644 vllm_omni/outputs/utils.py diff --git a/.gitignore b/.gitignore index d9e8bd68b..224dac867 100644 --- a/.gitignore +++ b/.gitignore @@ -248,6 +248,7 @@ datasets/ # Output files outputs/ +!vllm_omni/outputs/ results/ generated/ output_*/ diff --git a/docs/api/README.md b/docs/api/README.md index d4c580629..cfea6d175 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -46,17 +46,17 @@ Engine classes for offline and online inference. - [vllm_omni.engine.PromptEmbedsPayload][] - [vllm_omni.engine.arg_utils.OmniEngineArgs][] - [vllm_omni.engine.async_omni_engine.AsyncOmniEngine][] -- [vllm_omni.engine.mm_outputs.MultimodalCompletionOutput][] -- [vllm_omni.engine.mm_outputs.MultimodalPayload][] - [vllm_omni.engine.orchestrator.Orchestrator][] - [vllm_omni.engine.orchestrator.OrchestratorRequestState][] -- [vllm_omni.engine.output_modality.OutputModality][] -- [vllm_omni.engine.output_modality.TensorAccumulationStrategy][] -- [vllm_omni.engine.output_processor.MultimodalOutputProcessor][] -- [vllm_omni.engine.output_processor.OmniRequestState][] - [vllm_omni.engine.stage_engine_core_client.StageEngineCoreClient][] - [vllm_omni.engine.stage_init_utils.StageMetadata][] - [vllm_omni.engine.stage_init_utils.StartedLlmStage][] +- [vllm_omni.outputs.mm_outputs.MultimodalCompletionOutput][] +- [vllm_omni.outputs.mm_outputs.MultimodalPayload][] +- [vllm_omni.outputs.output_modality.OutputModality][] +- [vllm_omni.outputs.output_modality.TensorAccumulationStrategy][] +- [vllm_omni.outputs.output_processor.MultimodalOutputProcessor][] +- [vllm_omni.outputs.output_processor.OmniRequestState][] ## Core diff --git a/tests/engine/test_output_modality.py b/tests/engine/test_output_modality.py index 0abfe6ea0..ff15e5824 100644 --- a/tests/engine/test_output_modality.py +++ b/tests/engine/test_output_modality.py @@ -14,7 +14,7 @@ import torch # ── Load modules without triggering vllm_omni.__init__ ───────────── pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -_ENGINE_DIR = Path(__file__).resolve().parents[2] / "vllm_omni" / "engine" +_OUTPUTS_DIR = Path(__file__).resolve().parents[2] / "vllm_omni" / "outputs" def _load_module(name: str, filepath: Path): @@ -26,12 +26,16 @@ def _load_module(name: str, filepath: Path): _om_mod = _load_module( - "vllm_omni.engine.output_modality", - _ENGINE_DIR / "output_modality.py", + "vllm_omni.outputs.output_modality", + _OUTPUTS_DIR / "output_modality.py", +) +_load_module( + "vllm_omni.outputs.utils", + _OUTPUTS_DIR / "utils.py", ) _mm_mod = _load_module( - "vllm_omni.engine.mm_outputs", - _ENGINE_DIR / "mm_outputs.py", + "vllm_omni.outputs.mm_outputs", + _OUTPUTS_DIR / "mm_outputs.py", ) OutputModality = _om_mod.OutputModality diff --git a/tests/engine/test_output_processor.py b/tests/engine/test_output_processor.py index f9b08ca15..86e71fcbc 100644 --- a/tests/engine/test_output_processor.py +++ b/tests/engine/test_output_processor.py @@ -11,8 +11,8 @@ from vllm.outputs import PoolingRequestOutput from vllm.sampling_params import RequestOutputKind from vllm.v1.engine import FinishReason -from vllm_omni.engine.output_modality import OutputModalityNames -from vllm_omni.engine.output_processor import OmniRequestState +from vllm_omni.outputs.output_modality import OutputModalityNames +from vllm_omni.outputs.output_processor import OmniRequestState pytestmark = [pytest.mark.core_model, pytest.mark.cpu] diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 2fcfd43f7..bdf43711c 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -9,7 +9,7 @@ from vllm.engine.arg_utils import AsyncEngineArgs, EngineArgs from vllm.logger import init_logger from vllm_omni.config import OmniModelConfig -from vllm_omni.engine.output_modality import OutputModality +from vllm_omni.outputs.output_modality import OutputModality from vllm_omni.platforms import current_omni_platform from vllm_omni.plugins import load_omni_general_plugins diff --git a/vllm_omni/engine/mm_outputs.py b/vllm_omni/engine/mm_outputs.py index 3c9da2496..336bf7d9a 100644 --- a/vllm_omni/engine/mm_outputs.py +++ b/vllm_omni/engine/mm_outputs.py @@ -1,127 +1,9 @@ -"""Multimodal output data structures for vLLM-Omni. +import warnings -This module defines structured types for multimodal outputs. +from vllm_omni.outputs.mm_outputs import * # noqa: F401,F403 -""" - -from __future__ import annotations - -from collections.abc import Iterator, Mapping -from dataclasses import dataclass, field -from typing import Any - -import torch -from vllm.outputs import CompletionOutput - - -@dataclass(eq=False) -class MultimodalPayload(Mapping): - """Structured multimodal output payload. - - Implements ``collections.abc.Mapping`` so that ``isinstance(payload, dict)`` - style checks in downstream code can be replaced with duck-typing, and - ``payload.get(key)``, ``payload[key]``, ``key in payload``, ``len(payload)`` - all work seamlessly for both tensors and metadata. - - Attributes: - tensors: Dictionary mapping modality/key names to their tensors. - metadata: Optional dictionary for non-tensor metadata - (e.g., sample rate for audio, image dimensions). - """ - - tensors: dict[str, torch.Tensor] = field(default_factory=dict) - metadata: dict[str, Any] = field(default_factory=dict) - - @property - def primary_tensor(self) -> torch.Tensor | None: - """Return the first tensor in the payload, or None if empty.""" - if self.tensors: - return next(iter(self.tensors.values())) - return None - - @property - def is_empty(self) -> bool: - """Return True if the payload has no tensors and no metadata.""" - return len(self.tensors) == 0 and len(self.metadata) == 0 - - def get(self, key: str, default: Any = None) -> Any: - """Get a value by key, searching tensors first then metadata.""" - if key in self.tensors: - return self.tensors[key] - return self.metadata.get(key, default) - - def __contains__(self, key: str) -> bool: - return key in self.tensors or key in self.metadata - - def __getitem__(self, key: str) -> Any: - """Dict-like indexing: search tensors first, then metadata.""" - if key in self.tensors: - return self.tensors[key] - if key in self.metadata: - return self.metadata[key] - raise KeyError(key) - - def __len__(self) -> int: - return len(self.tensors) + len(self.metadata) - - def __iter__(self) -> Iterator[str]: - yield from self.tensors - yield from self.metadata - - def __bool__(self) -> bool: - return bool(self.tensors) or bool(self.metadata) - - def __eq__(self, other: object) -> bool: - """Support equality with plain dicts and other Mappings.""" - if isinstance(other, MultimodalPayload): - return self.tensors == other.tensors and self.metadata == other.metadata - if isinstance(other, Mapping): - return dict(self) == dict(other) - return NotImplemented - - def to_dict(self) -> dict[str, Any]: - """Convert back to a plain dict (tensors + metadata merged).""" - result: dict[str, Any] = dict(self.tensors) - result.update(self.metadata) - return result - - @classmethod - def from_dict(cls, data: dict[str, Any] | None) -> MultimodalPayload | None: - """Create a MultimodalPayload from a raw dictionary. - - Separates torch.Tensor values into tensors and everything - else into metadata. - """ - if not data: - return None - tensors: dict[str, torch.Tensor] = {} - metadata: dict[str, Any] = {} - for k, v in data.items(): - if isinstance(v, torch.Tensor): - tensors[k] = v - else: - metadata[k] = v - if not tensors and not metadata: - return None - return cls(tensors=tensors, metadata=metadata) - - -@dataclass -class MultimodalCompletionOutput(CompletionOutput): - """CompletionOutput with multimodal support. - - Inherits all CompletionOutput fields and adds multimodal_output. - As a CompletionOutput subclass, compatible with all existing vLLM consumers. - """ - - def __init__( - self, - multimodal_output: MultimodalPayload | None = None, - **kwargs: Any, - ): - super().__init__(**kwargs) - self.multimodal_output = multimodal_output - - def __repr__(self) -> str: - base = super().__repr__() - return f"{base[:-1]}, multimodal_output={self.multimodal_output!r})" +warnings.warn( + "Importing from 'vllm_omni.engine.mm_outputs' is deprecated. Use 'vllm_omni.outputs.mm_outputs' instead.", + DeprecationWarning, + stacklevel=2, +) diff --git a/vllm_omni/engine/output_modality.py b/vllm_omni/engine/output_modality.py index 2536a8ade..364519bed 100644 --- a/vllm_omni/engine/output_modality.py +++ b/vllm_omni/engine/output_modality.py @@ -1,120 +1,9 @@ -"""Output modality types for vLLM-Omni. +import warnings -This module defines the OutputModality enum and TensorAccumulationStrategy -for type-safe multimodal output routing and tensor merging. +from vllm_omni.outputs.output_modality import * # noqa: F401,F403 -""" - -from __future__ import annotations - -import re -from enum import Enum, Flag, StrEnum, auto - -_MODALITY_ALIASES: dict[str, str] = { - "speech": "audio", - "images": "image", - "latents": "latent", - "wav": "audio", - "waveform": "audio", - "pixel_values": "image", - "pixels": "image", - "token_ids": "text", - "tokens": "text", -} - - -class OutputModalityNames(StrEnum): - """Keys for output modalities. - - TODO: (Alex) Integrate this with the big-flag enum below + throughout the code - for better type safety (currently only used for output processor). - """ - - TEXT = "text" - IMAGE = "image" - AUDIO = "audio" - LATENT = "latent" - - -# Specify which output modalities may be drained when handling delta messages. -# For some types, e.g., latents, we need to be careful to ensure the full context -# is passed as the stream yields due to assumptions in the I/O processing and model -# when async chunk isn't enabled. -NON_DRAINABLE_MODALITIES = {OutputModalityNames.TEXT, OutputModalityNames.LATENT} -DRAINABLE_MODALITIES = {mod for mod in OutputModalityNames if mod not in NON_DRAINABLE_MODALITIES} - - -class OutputModality(Flag): - """Bit-flag enum for output modalities. - - Compose freely with ``|`` — no need to enumerate every combination. - - Single: ``OutputModality.TEXT``, ``OutputModality.IMAGE``, ... - Compound: ``OutputModality.TEXT | OutputModality.IMAGE`` (text+image) - - Note: POOLING is intentionally excluded. Pooling/embedding is vLLM's - native path (pooling_output → PoolingRequestOutput), handled entirely - by the base OutputProcessor. vLLM-Omni's layer does not participate. - """ - - TEXT = auto() - IMAGE = auto() - AUDIO = auto() - LATENT = auto() - - @classmethod - def from_string(cls, s: str | None) -> OutputModality: - """Parse a free-text modality string into an OutputModality flag. - - Handles common aliases and compound strings separated by + or ,. - - Examples:: - - OutputModality.from_string("text+image") - # → OutputModality.TEXT | OutputModality.IMAGE - """ - if not s or not s.strip(): - return cls.TEXT - - parts = [p.strip().lower() for p in re.split(r"[+,]", s.strip())] - result = cls(0) - for p in parts: - p = _MODALITY_ALIASES.get(p, p) - try: - result |= cls[p.upper()] - except KeyError: - raise ValueError(f"Unknown modality: {p!r}. Supported: {[m.name.lower() for m in cls]}") - return result - - @property - def has_text(self) -> bool: - return OutputModality.TEXT in self - - @property - def has_multimodal(self) -> bool: - return bool(self & ~OutputModality.TEXT) - - -class TensorAccumulationStrategy(Enum): - """Strategy for merging incremental multimodal tensors.""" - - CONCAT_DIM0 = "concat_dim0" - """Concatenate along dimension 0. Used for image/latent tensors.""" - - CONCAT_LAST = "concat_last" - """Concatenate along the last dimension. Used for audio waveforms.""" - - APPEND_LIST = "append_list" - """Append to a list (no tensor concatenation).""" - - REPLACE = "replace" - """Replace previous tensor entirely with the latest one.""" - - -def get_accumulation_strategy(modality: OutputModality) -> TensorAccumulationStrategy: - """Determine tensor merge strategy from the multimodal flags.""" - if OutputModality.AUDIO in modality: - return TensorAccumulationStrategy.CONCAT_LAST - if OutputModality.IMAGE in modality or OutputModality.LATENT in modality: - return TensorAccumulationStrategy.CONCAT_DIM0 - return TensorAccumulationStrategy.CONCAT_DIM0 # default +warnings.warn( + "Importing from 'vllm_omni.engine.output_modality' is deprecated. Use 'vllm_omni.outputs.output_modality' instead.", + DeprecationWarning, + stacklevel=2, +) diff --git a/vllm_omni/engine/output_processor.py b/vllm_omni/engine/output_processor.py index 71303269f..0d87583a8 100644 --- a/vllm_omni/engine/output_processor.py +++ b/vllm_omni/engine/output_processor.py @@ -1,762 +1,10 @@ -from dataclasses import fields as dataclass_fields -from typing import Any +import warnings -import torch -from vllm.logger import init_logger -from vllm.outputs import CompletionOutput, PoolingRequestOutput, RequestOutput -from vllm.sampling_params import RequestOutputKind -from vllm.tokenizers import TokenizerLike -from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason -from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor -from vllm.v1.engine.output_processor import ( - OutputProcessorOutput, - RequestOutputCollector, - RequestState, +from vllm_omni.outputs.output_processor import * # noqa: F401,F403 + +warnings.warn( + "Importing from 'vllm_omni.engine.output_processor' is deprecated. " + "Use 'vllm_omni.outputs.output_processor' instead.", + DeprecationWarning, + stacklevel=2, ) -from vllm.v1.engine.parallel_sampling import ParentRequest -from vllm.v1.metrics.stats import IterationStats, RequestStateStats - -from vllm_omni.data_entry_keys import unflatten_payload -from vllm_omni.engine.mm_outputs import MultimodalCompletionOutput, MultimodalPayload -from vllm_omni.engine.output_modality import ( - DRAINABLE_MODALITIES, - OutputModality, - TensorAccumulationStrategy, - get_accumulation_strategy, -) -from vllm_omni.outputs import OmniRequestOutput - -logger = init_logger(__name__) - -# Keys whose values may arrive as scalar tensors (e.g. sample rate) but -# represent metadata, not concatenatable data. When these appear in -# ``MultimodalPayload.tensors`` they must be relocated to ``.metadata`` -# before consolidation so the tensor concatenation path does not attempt -# a dimensioned cat on a 0-d tensor. -_METADATA_TENSOR_KEYS: frozenset[str] = frozenset({"sr", "sample_rate", "audio_sample_rate"}) - -# --------------------------------------------------------------------------- -# Module-level helpers -# --------------------------------------------------------------------------- - - -def _to_cpu(x: Any) -> Any: - """Move a tensor to CPU; pass through non-tensors unchanged.""" - if isinstance(x, torch.Tensor): - try: - return x.detach().to("cpu", non_blocking=True).contiguous() - except (RuntimeError, AttributeError): - return x - if isinstance(x, dict): - return {str(sk): _to_cpu(sv) for sk, sv in x.items()} - return x - - -def _merge_payload(target: MultimodalPayload, incoming: MultimodalPayload) -> None: - """Merge *incoming* into *target* using list-based deferred concatenation.""" - for k, v in incoming.tensors.items(): - if k not in target.tensors: - target.tensors[k] = v - else: - existing = target.tensors[k] - if isinstance(existing, list): - existing.append(v) - elif isinstance(existing, torch.Tensor) and isinstance(v, torch.Tensor): - target.tensors[k] = [existing, v] - else: - target.tensors[k] = v - - for k, v in incoming.metadata.items(): - if k not in target.metadata: - target.metadata[k] = v - else: - existing = target.metadata[k] - if isinstance(v, torch.Tensor) and isinstance(existing, torch.Tensor): - target.metadata[k] = [existing, v] - elif isinstance(v, torch.Tensor) and isinstance(existing, list): - existing.append(v) - else: - # Metadata: replace with latest value - target.metadata[k] = v - - -def _cat_tensors( - tensors: list[torch.Tensor], - strategy: TensorAccumulationStrategy, -) -> torch.Tensor: - """Concatenate a list of tensors according to *strategy*.""" - if strategy == TensorAccumulationStrategy.CONCAT_LAST: - return torch.cat(tensors, dim=-1) - if strategy == TensorAccumulationStrategy.REPLACE: - return tensors[-1] - # CONCAT_DIM0 / APPEND_LIST / default - return torch.cat(tensors, dim=0) - - -def _modality_to_type_string(modality: OutputModality) -> str: - """Convert an OutputModality flag to a lowercase type string.""" - try: - if OutputModality.AUDIO in modality: - return "audio" - if OutputModality.IMAGE in modality: - return "image" - if OutputModality.LATENT in modality: - return "latent" - except TypeError: - # Flag identity mismatch (e.g. after module reload in tests). - name = getattr(modality, "name", "") or "" - lowered = name.lower() - if "audio" in lowered: - return "audio" - if "image" in lowered: - return "image" - if "latent" in lowered: - return "latent" - return "text" - - -class OmniRequestState(RequestState): - """Request state for omni models, tracking multimodal outputs. - - Extends the base RequestState with support for accumulating - multimodal tensor outputs (e.g., images, audio, latents) that - are produced incrementally during generation. - """ - - def __init__( - self, - *args, - **kwargs, - ): - # arrival_time is always passed as a keyword argument by the sole caller - # (from_new_request). The *args fallback via positional index was removed - # because upstream RequestState.__init__ reorders parameters between - # releases, making args[12] fragile. - arrival_time = kwargs.get("arrival_time") - super().__init__(*args, **kwargs) - self.native_text_stats = RequestStateStats(arrival_time=float(arrival_time or 0.0)) - # Omni-specific: multimodal output accumulation - # TODO: mm_type is per-request, not per-key. If a model ever produces - # both audio and latent outputs, the modality type would flip on each - # add_multimodal_tensor() call. Consider tracking per-key modality - # types (e.g. dict[str, str]) for future multi-output models. - self.mm_type: str | None = None - self.mm_accumulated: MultimodalPayload = MultimodalPayload() - - def apply_streaming_update(self, update) -> None: - super().apply_streaming_update(update) - self.native_text_stats.arrival_time = update.arrival_time - - @staticmethod - def _to_cpu(x): - """Try to convert to CPU tensor if needed.""" - if isinstance(x, torch.Tensor): - try: - return x.detach().to("cpu", non_blocking=True).contiguous() - except Exception: - return x - return x - - def add_multimodal_tensor(self, payload: Any | None, mm_type: str | None) -> None: - """Accumulate a multimodal tensor payload into the request state. - - Normalizes incoming payloads (dict or raw tensor) into a - MultimodalPayload and merges with any previously accumulated data. - Uses list-based deferred concatenation to avoid O(n²) repeated - torch.cat calls. - """ - if payload is None: - return - try: - if mm_type: - self.mm_type = (mm_type or "").lower() - - target_key = self.mm_type or "hidden" - - # Build incoming MultimodalPayload - if isinstance(payload, dict): - remapped: dict[str, Any] = {} - for k, v in payload.items(): - # Normalize producer keys to the modality name. - # AR runners produce {"hidden": ...} and generation - # runners produce {"model_outputs": ...}; remap both - # to the semantic modality key (e.g. "audio", "latent"). - if k == "model_outputs": - k = target_key - elif k == "hidden" and target_key != "hidden": - k = target_key - remapped[k] = _to_cpu(v) - incoming = MultimodalPayload.from_dict(remapped) - elif isinstance(payload, MultimodalPayload): - incoming = payload - else: - incoming = MultimodalPayload.from_dict({target_key: _to_cpu(payload)}) - - if incoming is None: - return - - if self.mm_accumulated.is_empty: - self.mm_accumulated = incoming - else: - _merge_payload(self.mm_accumulated, incoming) - except (ValueError, TypeError, RuntimeError): - logger.exception("Error accumulating multimodal tensor") - - def _consolidate_multimodal_tensors(self) -> None: - """Consolidate accumulated tensor lists into single tensors. - - Uses TensorAccumulationStrategy derived from the output modality - to determine concatenation behavior. Metadata values always use - REPLACE (keep latest). - """ - if self.mm_accumulated.is_empty: - return - - try: - modality = OutputModality.from_string(self.mm_type) - except (ValueError, KeyError): - modality = OutputModality.TEXT - strategy = get_accumulation_strategy(modality) - - # Move known metadata scalar tensors (e.g. sample rate) from - # tensors to metadata so they are handled by the REPLACE path - # below instead of failing tensor concatenation. - for key in _METADATA_TENSOR_KEYS: - v = self.mm_accumulated.tensors.pop(key, None) - if v is not None: - if isinstance(v, list): - # Keep the last value (REPLACE semantics) - self.mm_accumulated.metadata[key] = v[-1] - else: - self.mm_accumulated.metadata[key] = v - - try: - for k, v in list(self.mm_accumulated.tensors.items()): - if isinstance(v, list) and v and isinstance(v[0], torch.Tensor): - try: - self.mm_accumulated.tensors[k] = _cat_tensors(v, strategy) - except RuntimeError: - if k == "audio": - try: - self.mm_accumulated.tensors[k] = torch.cat(v, dim=-1) - except RuntimeError: - self.mm_accumulated.tensors[k] = torch.cat([t.reshape(-1) for t in v], dim=0) - else: - logger.warning( - "Error concatenating tensor for key %s; keeping last tensor", - k, - ) - self.mm_accumulated.tensors[k] = v[-1] - - # Metadata: consolidate any deferred tensor lists (REPLACE semantics) - for k, v in list(self.mm_accumulated.metadata.items()): - if isinstance(v, list) and v and isinstance(v[0], torch.Tensor): - self.mm_accumulated.metadata[k] = v[-1] - elif isinstance(v, dict): - for sk, sv in list(v.items()): - if isinstance(sv, list) and sv and isinstance(sv[0], torch.Tensor): - v[sk] = sv[-1] - except (RuntimeError, TypeError, KeyError): - logger.exception("Error consolidating multimodal tensors") - - # Override: do not route to pooling-only path; always create completion - # outputs, and attach pooling_result into the CompletionOutput. - def make_request_output( - self, - new_token_ids: list[int], - pooling_output: torch.Tensor | None, - finish_reason: FinishReason | None, - stop_reason: int | str | None, - kv_transfer_params: dict[str, Any] | None = None, - routed_experts: Any = None, - ) -> OmniRequestOutput | PoolingRequestOutput | None: - """Create a request output from generation results. - - Creates a RequestOutput or PoolingRequestOutput from the generated - tokens and accumulated multimodal outputs. Attaches multimodal - tensors to the completion output if available. - - Args: - new_token_ids: List of newly generated token IDs - pooling_output: Optional pooling output tensor - finish_reason: Optional finish reason indicating why generation stopped - stop_reason: Optional stop reason (token ID or stop string) - kv_transfer_params: Optional KV cache transfer parameters - routed_experts: Optional MoE routed-expert ids for this step, - attached to the completion output for generation stages - - Returns: - OmniRequestOutput or PoolingRequestOutput if output should be - emitted (based on finish status and output kind), None otherwise - """ - # Pooling-only requests should follow base behavior. - if self.detokenizer is None and pooling_output is not None: - return super().make_request_output( - new_token_ids, - pooling_output, - finish_reason, - stop_reason, - kv_transfer_params, - ) - - finished = finish_reason is not None - final_only = self.output_kind == RequestOutputKind.FINAL_ONLY - is_delta = self.output_kind == RequestOutputKind.DELTA - - if not finished and final_only: - return None - - # Consolidate accumulated tensors when finishing, or for every - # CUMULATIVE step so the consumer always sees a single tensor. - if finished or not is_delta: - self._consolidate_multimodal_tensors() - - if self.stream_interval > 1 and self.detokenizer is not None: - # Send output request only when - # 1. It has finished, or - # 2. It is the first token, or - # 3. It has reached the stream interval number of tokens - if not ( - finished - or self.sent_tokens_offset == 0 - or self.detokenizer.num_output_tokens() - self.sent_tokens_offset >= self.stream_interval - ): - return None - - if self.output_kind == RequestOutputKind.DELTA: - # Send tokens from the offset in DELTA mode, otherwise all - # tokens are sent. - new_token_ids = self.detokenizer.output_token_ids[self.sent_tokens_offset :] - self.sent_tokens_offset = self.detokenizer.num_output_tokens() - - external_req_id = self.external_req_id - - output = self._new_completion_output(new_token_ids, finish_reason, stop_reason, routed_experts) - - if self.parent_req is None: - outputs = [output] - else: - outputs, finished = self.parent_req.get_outputs(self.request_id, output) - if not outputs: - return None - external_req_id = self.parent_req.external_req_id - - return self._new_request_output( - external_req_id, - outputs, - finished, - kv_transfer_params, - ) - - def _new_completion_output( - self, - token_ids: list[int], - finish_reason: FinishReason | None, - stop_reason: int | str | None, - routed_experts: Any = None, - ) -> MultimodalCompletionOutput | CompletionOutput: - """Create a completion output with multimodal data attached. - - Returns a MultimodalCompletionOutput when multimodal data has been - accumulated, otherwise returns the base CompletionOutput. Snapshots - the accumulated payload before draining DELTA-mode modality keys so - that callers receive an immutable view. - """ - # When there is no detokenizer (generation stages), build a minimal - # CompletionOutput directly since upstream asserts detokenizer != None. - if self.detokenizer is None: - finished = finish_reason is not None - base_output = CompletionOutput( - index=self.request_index, - text="", - token_ids=token_ids, - logprobs=None, - cumulative_logprob=None, - finish_reason=str(finish_reason) if finished else None, - stop_reason=stop_reason if finished else None, - routed_experts=routed_experts, - ) - else: - base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason) - - # Always provide cumulative token IDs for inter-stage processors. - if self.detokenizer is not None: - base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids) - else: - base_output.cumulative_token_ids = list(token_ids) - - # Attach cumulative_text only at the final step for inter-stage use. - if finish_reason is not None and hasattr(self.detokenizer, "output_text"): - base_output.cumulative_text = self.detokenizer.output_text - - try: - if self.mm_accumulated and not self.mm_accumulated.is_empty: - # Snapshot: copy current tensors/metadata so drain doesn't - # mutate the payload already handed to the caller. - # Unflatten dotted keys (e.g. "hidden_states.layer_0") back - # to nested dicts so downstream consumers (thinker2talker etc.) - # can access with .get("hidden_states", {}).get("layers", {}). - merged = {**self.mm_accumulated.tensors, **self.mm_accumulated.metadata} - nested = unflatten_payload(merged) - snapshot = MultimodalPayload.from_dict(nested) or MultimodalPayload() - kwargs = {f.name: getattr(base_output, f.name) for f in dataclass_fields(CompletionOutput)} - output = MultimodalCompletionOutput( - multimodal_output=snapshot, - **kwargs, - ) - output.cumulative_token_ids = base_output.cumulative_token_ids - if hasattr(base_output, "cumulative_text"): - output.cumulative_text = base_output.cumulative_text - - # DELTA mode: drain modality keys (e.g. audio) so the next - # step only sees freshly accumulated data for those keys. - if self.output_kind == RequestOutputKind.DELTA: - for modality_key in DRAINABLE_MODALITIES: - self.mm_accumulated.tensors.pop(modality_key, None) - - return output - except (RuntimeError, TypeError, AttributeError): - logger.exception("Error creating MultimodalCompletionOutput") - return base_output - - def _new_request_output( - self, - external_req_id: str, - outputs: list, - finished: bool, - kv_transfer_params: dict[str, Any] | None = None, - ) -> RequestOutput | PoolingRequestOutput: - """Create request output, handling no-detokenizer generation stages. - - Upstream asserts ``self.logprobs_processor is not None`` which fails - for generation stages (talker, code2wav) that have no tokenizer. - When the logprobs processor is absent we build the RequestOutput - directly with ``prompt_logprobs=None``. - """ - if self.logprobs_processor is not None: - return super()._new_request_output( - external_req_id, - outputs, - finished, - kv_transfer_params, - ) - - # No-detokenizer path: build RequestOutput directly. - prompt_token_ids = self.prompt_token_ids - if prompt_token_ids is None and self.prompt_embeds is not None: - prompt_token_ids = [0] * len(self.prompt_embeds) - if prompt_token_ids is None: - prompt_token_ids = [] - - return RequestOutput( - request_id=external_req_id, - lora_request=self.lora_request, - prompt=self.prompt, - prompt_token_ids=prompt_token_ids, - prompt_logprobs=None, - outputs=outputs, - finished=finished, - kv_transfer_params=kv_transfer_params, - num_cached_tokens=self.num_cached_tokens, - metrics=self.stats, - ) - - -class MultimodalOutputProcessor(VLLMOutputProcessor): - """Handles multimodal output processing. - - Captures multimodal outputs from OmniEngineCoreOutput and accumulates - them as MultimodalPayload in OmniRequestState, before delegating to - the base vLLM OutputProcessor for text handling. - - The data flow is: - 1. For each EngineCoreOutput with multimodal_output: - - Capture into OmniRequestState.add_multimodal_tensor() - 2. Base vLLM OutputProcessor handles text detokenization - 3. On finish, _consolidate_multimodal_tensors() concatenates accumulated - tensors using strategy-based dispatch - 4. _new_completion_output() returns MultimodalCompletionOutput - """ - - def __init__( - self, - tokenizer: TokenizerLike | None, - *, - log_stats: bool, - stream_interval: int = 1, - tracing_enabled: bool = False, - engine_core_output_type: str | None = None, - output_modality: OutputModality = OutputModality.TEXT, - ): - """Initialize the multimodal output processor. - - Args: - tokenizer: Tokenizer for detokenizing text outputs - log_stats: Whether to log statistics - stream_interval: Stream interval for output generation - engine_core_output_type: Optional output type string (e.g., - "image", "audio", "latent"). Converted to OutputModality - internally. Kept for backward compatibility with - stage_init_utils. - output_modality: Type-safe output modality flag. Used to tag - multimodal outputs with the correct modality key when - per-output type info is unavailable. - """ - super().__init__( - tokenizer=tokenizer, - log_stats=log_stats, - stream_interval=stream_interval, - tracing_enabled=tracing_enabled, - ) - # Convert string-based engine_core_output_type to OutputModality - if engine_core_output_type is not None: - self.output_modality = OutputModality.from_string(engine_core_output_type) - else: - self.output_modality = output_modality - self.engine_core_output_type = engine_core_output_type - self._native_text_metrics_by_request: dict[str, dict[str, Any]] = {} - - def _native_text_metric_record(self, request_id: str) -> dict[str, Any]: - return self._native_text_metrics_by_request.setdefault( - request_id, - { - "vllm_ttft_ms": 0.0, - "vllm_tpot_ms": 0.0, - "vllm_itl_ms": 0.0, - "vllm_itls_ms": [], - }, - ) - - def pop_native_text_metrics(self, request_id: str) -> dict[str, Any]: - return self._native_text_metrics_by_request.pop(request_id, {}) - - def abort_requests(self, request_ids, internal: bool) -> list[str]: - request_ids = list(request_ids) - for request_id in request_ids: - if internal: - req_state = self.request_states.get(request_id) - if req_state is not None: - self._native_text_metrics_by_request.pop(req_state.external_req_id, None) - else: - self._native_text_metrics_by_request.pop(request_id, None) - return super().abort_requests(request_ids, internal) - - def add_request( - self, - request: EngineCoreRequest, - prompt: str | None, - parent_req: ParentRequest | None = None, - request_index: int = 0, - queue: RequestOutputCollector | None = None, - ) -> None: - """Add a new request to be processed. - - Creates an OmniRequestState for the request and registers it - for output processing. - - Args: - request: Engine core request to add - prompt: Optional prompt string for the request - parent_req: Optional parent request for parallel sampling - request_index: Index of the request in the batch - queue: Optional queue for collecting outputs - - Raises: - ValueError: If the request ID is already registered - """ - request_id = request.request_id - req_state = self.request_states.get(request_id) - if req_state is not None: - self._update_streaming_request_state(req_state, request, prompt) - return - - req_state = OmniRequestState.from_new_request( - tokenizer=self.tokenizer, - request=request, - prompt=prompt, - parent_req=parent_req, - request_index=request_index, - queue=queue, - log_stats=self.log_stats, - stream_interval=self.stream_interval, - ) - self.request_states[request_id] = req_state - if parent_req: - self.parent_requests[parent_req.request_id] = parent_req - self.external_req_ids[req_state.external_req_id].append(request_id) - - def remove_request(self, request_id: str) -> None: - """Rollback one previously registered request if it was never submitted.""" - req_state = self.request_states.pop(request_id, None) - if req_state is None: - return - - external_req_id = getattr(req_state, "external_req_id", None) - if external_req_id is not None: - self._native_text_metrics_by_request.pop(external_req_id, None) - request_ids = self.external_req_ids.get(external_req_id) - if request_ids is not None: - self.external_req_ids[external_req_id] = [rid for rid in request_ids if rid != request_id] - if not self.external_req_ids[external_req_id]: - self.external_req_ids.pop(external_req_id, None) - - parent_req = getattr(req_state, "parent_req", None) - if parent_req is not None: - self.parent_requests.pop(parent_req.request_id, None) - - def process_outputs( - self, - engine_core_outputs: list[EngineCoreOutput], - engine_core_timestamp: float | None = None, - iteration_stats: IterationStats | None = None, - ) -> OutputProcessorOutput: - default_mm_type = _modality_to_type_string(self.output_modality) - - # Separate outputs that upstream can handle (has detokenizer or - # pooling) from multimodal-only outputs (no detokenizer, no pooling) - # that would trigger upstream's `assert detokenizer is not None`. - upstream_outputs: list[EngineCoreOutput] = [] - mm_only_outputs: list[EngineCoreOutput] = [] - - for eco in engine_core_outputs: - req_state = self.request_states.get(eco.request_id) - if req_state is None: - continue - - # Accumulate multimodal tensors regardless of path. - if isinstance(req_state, OmniRequestState): - mm_output = getattr(eco, "multimodal_output", None) - if mm_output is not None: - mm_type = getattr(eco, "output_type", None) or default_mm_type - req_state.add_multimodal_tensor(mm_output, mm_type) - - # Route: if no detokenizer and no pooling output, handle locally - # to avoid upstream's assert on detokenizer. - if req_state.detokenizer is None and eco.pooling_output is None: - mm_only_outputs.append(eco) - else: - upstream_outputs.append(eco) - - # Handle multimodal-only outputs (generation stages) locally. - self._process_mm_only_outputs(mm_only_outputs) - - # Delegate text/pooling outputs to upstream. - return super().process_outputs( - upstream_outputs, - engine_core_timestamp=engine_core_timestamp, - iteration_stats=iteration_stats, - ) - - def _process_mm_only_outputs( - self, - engine_core_outputs: list[EngineCoreOutput], - ) -> None: - """Handle outputs from generation stages that have no detokenizer. - - These cannot go through upstream process_outputs because it asserts - detokenizer is not None when pooling_output is None. - """ - for eco in engine_core_outputs: - req_state = self.request_states.get(eco.request_id) - if req_state is None or not isinstance(req_state, OmniRequestState): - continue - - new_token_ids = eco.new_token_ids - finish_reason = eco.finish_reason - stop_reason = eco.stop_reason - kv_transfer_params = eco.kv_transfer_params - routed_experts = eco.routed_experts - req_state.num_cached_tokens = eco.num_cached_tokens - req_state.is_prefilling = False - - if request_output := req_state.make_request_output( - new_token_ids, - None, # pooling_output - finish_reason, - stop_reason, - kv_transfer_params, - routed_experts, - ): - if req_state.queue is not None: - req_state.queue.put(request_output) - - if finish_reason is not None: - self._finish_request(req_state) - - def _update_stats_from_output( - self, - req_state: RequestState, - engine_core_output: EngineCoreOutput, - engine_core_timestamp: float | None, - iteration_stats: IterationStats | None, - ): - was_prefilling = req_state.is_prefilling - native_stats = req_state.native_text_stats if isinstance(req_state, OmniRequestState) else None - previous_last_token_ts = native_stats.last_token_ts if native_stats is not None else 0.0 - - # NOTE: We pass ``None`` for *iteration_stats* to the parent so that - # the upstream's ``_update_stats_from_output`` logs stats via its own - # ``RequestStateStats`` (req_state.stats) without touching the omni - # ``IterationStats`` object. Below we re-apply the same update using - # *native_stats* (the omni-specific ``RequestStateStats`` attached to - # ``OmniRequestState``), which correctly feeds the omni metrics path. - # If the upstream adds new logic in ``_update_stats_from_output`` (e.g. - # additional stat fields), this override must be kept in sync. - super()._update_stats_from_output( - req_state, - engine_core_output, - engine_core_timestamp, - None, - ) - - if iteration_stats is None or engine_core_timestamp is None or native_stats is None: - return - - iteration_stats.update_from_output( - engine_core_output, - engine_core_timestamp, - was_prefilling, - native_stats, - self.lora_states, - req_state.lora_name, - ) - record = self._native_text_metric_record(req_state.external_req_id) - if was_prefilling: - record["vllm_ttft_ms"] = max(float(native_stats.first_token_latency) * 1000.0, 0.0) - return - - if previous_last_token_ts > 0: - itl_ms = max((engine_core_timestamp - previous_last_token_ts) * 1000.0, 0.0) - itls_ms = record.setdefault("vllm_itls_ms", []) - itls_ms.append(itl_ms) - record["vllm_itl_ms"] = sum(itls_ms) / float(len(itls_ms)) - - def _update_stats_from_finished( - self, - req_state: RequestState, - finish_reason: FinishReason | None, - iteration_stats: IterationStats | None, - ): - super()._update_stats_from_finished(req_state, finish_reason, None) - - native_stats = req_state.native_text_stats if isinstance(req_state, OmniRequestState) else None - if iteration_stats is None or native_stats is None or finish_reason is None: - return - - iteration_stats.update_from_finished_request( - finish_reason=finish_reason, - request_id=req_state.external_req_id, - num_prompt_tokens=req_state.prompt_len, - max_tokens_param=req_state.max_tokens_param, - req_stats=native_stats, - num_cached_tokens=req_state.num_cached_tokens, - ) - if not iteration_stats.finished_requests: - return - - finished_request = iteration_stats.finished_requests[-1] - if finished_request.request_id != req_state.external_req_id: - return - self._native_text_metric_record(req_state.external_req_id)["vllm_tpot_ms"] = ( - float(finished_request.mean_time_per_output_token) * 1000.0 - ) diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index c3cf11b72..d43160895 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -27,11 +27,11 @@ from vllm.v1.executor import Executor from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.engine.arg_utils import OmniEngineArgs -from vllm_omni.engine.output_processor import MultimodalOutputProcessor from vllm_omni.entrypoints.stage_utils import _to_dict, set_stage_devices from vllm_omni.entrypoints.utils import filter_dataclass_kwargs, resolve_model_config_path from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams from vllm_omni.inputs.preprocess import OmniInputPreprocessor +from vllm_omni.outputs.output_processor import MultimodalOutputProcessor from vllm_omni.platforms import current_omni_platform from vllm_omni.quantization.inc_config import OmniINCConfig diff --git a/vllm_omni/outputs/mm_outputs.py b/vllm_omni/outputs/mm_outputs.py new file mode 100644 index 000000000..8c5fe6442 --- /dev/null +++ b/vllm_omni/outputs/mm_outputs.py @@ -0,0 +1,261 @@ +"""Multimodal output data structures for vLLM-Omni. + +This module defines structured types for multimodal outputs. + +""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field +from typing import Any + +import torch +from vllm.logger import init_logger +from vllm.outputs import CompletionOutput + +from vllm_omni.outputs.output_modality import TensorAccumulationStrategy +from vllm_omni.outputs.utils import _is_tensor_list, _to_cpu + +logger = init_logger(__name__) + +# Keys whose values are metadata scalars (e.g. audio sample rate) but may +# arrive as 0-d torch.Tensors — from_dict routes all tensors into .tensors, +# so we relocate them to .metadata before consolidation to avoid a bogus +# torch.cat attempt and its warn-and-keep-last fallback. +_METADATA_TENSOR_KEYS: frozenset[str] = frozenset({"sr", "sample_rate", "audio_sample_rate"}) + + +def _cat_tensors( + tensors: list[torch.Tensor], + strategy: TensorAccumulationStrategy, +) -> torch.Tensor: + """Concatenate a list of tensors according to *strategy*.""" + if strategy == TensorAccumulationStrategy.CONCAT_LAST: + return torch.cat(tensors, dim=-1) + if strategy == TensorAccumulationStrategy.REPLACE: + return tensors[-1] + # CONCAT_DIM0 / APPEND_LIST / default + return torch.cat(tensors, dim=0) + + +def _consolidate_tensor_list( + key: str, + tensor_list: list[torch.Tensor], + strategy: TensorAccumulationStrategy, +) -> torch.Tensor: + """Concatenate a deferred tensor list, with fallbacks on shape mismatch.""" + try: + return _cat_tensors(tensor_list, strategy) + except RuntimeError: + # [TODO: this part is for async chunk, not for audio only. can we come up with a design + # that identify this by whether using async chunk instead of modality?] + if key != "audio": + logger.warning("Error concatenating tensor for key %s; keeping last tensor", key) + return tensor_list[-1] + # Audio chunks may have mismatched shapes; retry along the last dim, + # then fall back to flattening each chunk. + try: + return torch.cat(tensor_list, dim=-1) + except RuntimeError: + return torch.cat([chunk.reshape(-1) for chunk in tensor_list], dim=0) + + +def _append_entries(store: dict[str, Any], incoming: dict[str, Any]) -> None: + """Append *incoming* entries into *store*. + + Tensor values are collected into lists (deferred concatenation, avoiding + O(n²) repeated torch.cat); non-tensor values replace the existing entry. + """ + for key, new_value in incoming.items(): + existing = store.get(key) + if isinstance(existing, list) and isinstance(new_value, torch.Tensor): + existing.append(new_value) + elif isinstance(existing, torch.Tensor) and isinstance(new_value, torch.Tensor): + store[key] = [existing, new_value] + else: + store[key] = new_value + + +@dataclass(eq=False) +class MultimodalPayload(Mapping): + """Structured multimodal output payload. + + Implements ``collections.abc.Mapping`` so that ``isinstance(payload, dict)`` + style checks in downstream code can be replaced with duck-typing, and + ``payload.get(key)``, ``payload[key]``, ``key in payload``, ``len(payload)`` + all work seamlessly for both tensors and metadata. + + Attributes: + tensors: Dictionary mapping modality/key names to their tensors. + metadata: Optional dictionary for non-tensor metadata + (e.g., sample rate for audio, image dimensions). + """ + + tensors: dict[str, torch.Tensor] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def primary_tensor(self) -> torch.Tensor | None: + """Return the first tensor in the payload, or None if empty.""" + if self.tensors: + return next(iter(self.tensors.values())) + return None + + @property + def is_empty(self) -> bool: + """Return True if the payload has no tensors and no metadata.""" + return len(self.tensors) == 0 and len(self.metadata) == 0 + + def get(self, key: str, default: Any = None) -> Any: + """Get a value by key, searching tensors first then metadata.""" + if key in self.tensors: + return self.tensors[key] + return self.metadata.get(key, default) + + def __contains__(self, key: str) -> bool: + return key in self.tensors or key in self.metadata + + def __getitem__(self, key: str) -> Any: + """Dict-like indexing: search tensors first, then metadata.""" + if key in self.tensors: + return self.tensors[key] + if key in self.metadata: + return self.metadata[key] + raise KeyError(key) + + def __len__(self) -> int: + return len(self.tensors) + len(self.metadata) + + def __iter__(self) -> Iterator[str]: + yield from self.tensors + yield from self.metadata + + def __bool__(self) -> bool: + return bool(self.tensors) or bool(self.metadata) + + def __eq__(self, other: object) -> bool: + """Support equality with plain dicts and other Mappings.""" + if isinstance(other, MultimodalPayload): + return self.tensors == other.tensors and self.metadata == other.metadata + if isinstance(other, Mapping): + return dict(self) == dict(other) + return NotImplemented + + def merged_with(self, incoming: MultimodalPayload) -> MultimodalPayload: + """Merge *incoming* onto this payload and return the result. + + Tensor values accumulate into lists for deferred concatenation; + non-tensor values are replaced with the latest. When this payload + is empty, *incoming* is returned as-is, so callers should use the + return value: ``accumulated = accumulated.merged_with(incoming)``. + """ + if self.is_empty: + return incoming + _append_entries(self.tensors, incoming.tensors) + _append_entries(self.metadata, incoming.metadata) + return self + + def consolidate_tensors(self, strategy: TensorAccumulationStrategy) -> None: + """Concatenate deferred tensor lists into single tensors. + + Tensors are generated content accumulated as chunks, so lists are + concatenated according to *strategy* (e.g. audio chunks along the + time dimension, latent frames along the batch dimension). + """ + # Relocate scalar metadata tensors (e.g. sample rate) that from_dict + # routed into .tensors, so they take the REPLACE path via .metadata + # instead of failing torch.cat as 0-d tensors. + for key in _METADATA_TENSOR_KEYS: + value = self.tensors.pop(key, None) + if value is None: + continue + self.metadata[key] = value[-1] if isinstance(value, list) else value + + for key, value in list(self.tensors.items()): + if _is_tensor_list(value): + self.tensors[key] = _consolidate_tensor_list(key, value, strategy) + + def consolidate_metadata(self) -> None: + """Resolve deferred tensor lists in metadata by keeping the latest value. + + Metadata values are per-step snapshots (e.g. sample rate), not + content deltas, so the latest value supersedes earlier ones. + Nested dicts (unflattened payloads) are resolved one level down. + """ + for key, value in list(self.metadata.items()): + if _is_tensor_list(value): + self.metadata[key] = value[-1] + elif isinstance(value, dict): + for sub_key, sub_value in list(value.items()): + if _is_tensor_list(sub_value): + value[sub_key] = sub_value[-1] + + def to_dict(self) -> dict[str, Any]: + """Convert back to a plain dict (tensors + metadata merged).""" + result: dict[str, Any] = dict(self.tensors) + result.update(self.metadata) + return result + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> MultimodalPayload | None: + """Create a MultimodalPayload from a raw dictionary. + + Separates torch.Tensor values into tensors and everything + else into metadata. + """ + if not data: + return None + tensors: dict[str, torch.Tensor] = {} + metadata: dict[str, Any] = {} + for k, v in data.items(): + if isinstance(v, torch.Tensor): + tensors[k] = v + else: + metadata[k] = v + if not tensors and not metadata: + return None + return cls(tensors=tensors, metadata=metadata) + + @classmethod + def from_raw(cls, payload: Any, modality_key: str) -> MultimodalPayload | None: + """Create a MultimodalPayload from a raw producer payload. + + Accepts a MultimodalPayload (returned as-is), a dict, or a bare + tensor (stored under *modality_key*). Tensors are moved to CPU. + Producer-specific dict keys are remapped to the semantic modality + key (e.g. "audio", "latent"): AR runners produce {"hidden": ...} + and generation runners produce {"model_outputs": ...}. + """ + if isinstance(payload, MultimodalPayload): + return payload + + if not isinstance(payload, dict): + return cls.from_dict({modality_key: _to_cpu(payload)}) + + remapped: dict[str, Any] = {} + for key, value in payload.items(): + is_producer_key = key == "model_outputs" or (key == "hidden" and modality_key != "hidden") + remapped[modality_key if is_producer_key else key] = _to_cpu(value) + return cls.from_dict(remapped) + + +@dataclass +class MultimodalCompletionOutput(CompletionOutput): + """CompletionOutput with multimodal support. + + Inherits all CompletionOutput fields and adds multimodal_output. + As a CompletionOutput subclass, compatible with all existing vLLM consumers. + """ + + def __init__( + self, + multimodal_output: MultimodalPayload | None = None, + **kwargs: Any, + ): + super().__init__(**kwargs) + self.multimodal_output = multimodal_output + + def __repr__(self) -> str: + base = super().__repr__() + return f"{base[:-1]}, multimodal_output={self.multimodal_output!r})" diff --git a/vllm_omni/outputs/output_modality.py b/vllm_omni/outputs/output_modality.py new file mode 100644 index 000000000..2536a8ade --- /dev/null +++ b/vllm_omni/outputs/output_modality.py @@ -0,0 +1,120 @@ +"""Output modality types for vLLM-Omni. + +This module defines the OutputModality enum and TensorAccumulationStrategy +for type-safe multimodal output routing and tensor merging. + +""" + +from __future__ import annotations + +import re +from enum import Enum, Flag, StrEnum, auto + +_MODALITY_ALIASES: dict[str, str] = { + "speech": "audio", + "images": "image", + "latents": "latent", + "wav": "audio", + "waveform": "audio", + "pixel_values": "image", + "pixels": "image", + "token_ids": "text", + "tokens": "text", +} + + +class OutputModalityNames(StrEnum): + """Keys for output modalities. + + TODO: (Alex) Integrate this with the big-flag enum below + throughout the code + for better type safety (currently only used for output processor). + """ + + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + LATENT = "latent" + + +# Specify which output modalities may be drained when handling delta messages. +# For some types, e.g., latents, we need to be careful to ensure the full context +# is passed as the stream yields due to assumptions in the I/O processing and model +# when async chunk isn't enabled. +NON_DRAINABLE_MODALITIES = {OutputModalityNames.TEXT, OutputModalityNames.LATENT} +DRAINABLE_MODALITIES = {mod for mod in OutputModalityNames if mod not in NON_DRAINABLE_MODALITIES} + + +class OutputModality(Flag): + """Bit-flag enum for output modalities. + + Compose freely with ``|`` — no need to enumerate every combination. + + Single: ``OutputModality.TEXT``, ``OutputModality.IMAGE``, ... + Compound: ``OutputModality.TEXT | OutputModality.IMAGE`` (text+image) + + Note: POOLING is intentionally excluded. Pooling/embedding is vLLM's + native path (pooling_output → PoolingRequestOutput), handled entirely + by the base OutputProcessor. vLLM-Omni's layer does not participate. + """ + + TEXT = auto() + IMAGE = auto() + AUDIO = auto() + LATENT = auto() + + @classmethod + def from_string(cls, s: str | None) -> OutputModality: + """Parse a free-text modality string into an OutputModality flag. + + Handles common aliases and compound strings separated by + or ,. + + Examples:: + + OutputModality.from_string("text+image") + # → OutputModality.TEXT | OutputModality.IMAGE + """ + if not s or not s.strip(): + return cls.TEXT + + parts = [p.strip().lower() for p in re.split(r"[+,]", s.strip())] + result = cls(0) + for p in parts: + p = _MODALITY_ALIASES.get(p, p) + try: + result |= cls[p.upper()] + except KeyError: + raise ValueError(f"Unknown modality: {p!r}. Supported: {[m.name.lower() for m in cls]}") + return result + + @property + def has_text(self) -> bool: + return OutputModality.TEXT in self + + @property + def has_multimodal(self) -> bool: + return bool(self & ~OutputModality.TEXT) + + +class TensorAccumulationStrategy(Enum): + """Strategy for merging incremental multimodal tensors.""" + + CONCAT_DIM0 = "concat_dim0" + """Concatenate along dimension 0. Used for image/latent tensors.""" + + CONCAT_LAST = "concat_last" + """Concatenate along the last dimension. Used for audio waveforms.""" + + APPEND_LIST = "append_list" + """Append to a list (no tensor concatenation).""" + + REPLACE = "replace" + """Replace previous tensor entirely with the latest one.""" + + +def get_accumulation_strategy(modality: OutputModality) -> TensorAccumulationStrategy: + """Determine tensor merge strategy from the multimodal flags.""" + if OutputModality.AUDIO in modality: + return TensorAccumulationStrategy.CONCAT_LAST + if OutputModality.IMAGE in modality or OutputModality.LATENT in modality: + return TensorAccumulationStrategy.CONCAT_DIM0 + return TensorAccumulationStrategy.CONCAT_DIM0 # default diff --git a/vllm_omni/outputs/output_processor.py b/vllm_omni/outputs/output_processor.py new file mode 100644 index 000000000..e39b995b0 --- /dev/null +++ b/vllm_omni/outputs/output_processor.py @@ -0,0 +1,633 @@ +from dataclasses import fields as dataclass_fields +from typing import Any + +import torch +from vllm.logger import init_logger +from vllm.outputs import CompletionOutput, PoolingRequestOutput, RequestOutput +from vllm.sampling_params import RequestOutputKind +from vllm.tokenizers import TokenizerLike +from vllm.v1.engine import EngineCoreOutput, EngineCoreRequest, FinishReason +from vllm.v1.engine.output_processor import OutputProcessor as VLLMOutputProcessor +from vllm.v1.engine.output_processor import ( + OutputProcessorOutput, + RequestOutputCollector, + RequestState, +) +from vllm.v1.engine.parallel_sampling import ParentRequest +from vllm.v1.metrics.stats import IterationStats, RequestStateStats + +from vllm_omni.data_entry_keys import unflatten_payload +from vllm_omni.outputs import OmniRequestOutput +from vllm_omni.outputs.mm_outputs import MultimodalCompletionOutput, MultimodalPayload +from vllm_omni.outputs.output_modality import ( + DRAINABLE_MODALITIES, + OutputModality, + get_accumulation_strategy, +) + +logger = init_logger(__name__) + + +# --------------------------------------------------------------------------- +# Module-level helpers +# --------------------------------------------------------------------------- + + +def _modality_to_type_string(modality: OutputModality) -> str: + """Convert an OutputModality flag to a lowercase type string.""" + try: + if OutputModality.AUDIO in modality: + return "audio" + if OutputModality.IMAGE in modality: + return "image" + if OutputModality.LATENT in modality: + return "latent" + except TypeError: + # Flag identity mismatch (e.g. after module reload in tests). + name = getattr(modality, "name", "") or "" + lowered = name.lower() + if "audio" in lowered: + return "audio" + if "image" in lowered: + return "image" + if "latent" in lowered: + return "latent" + return "text" + + +class OmniRequestState(RequestState): + """Request state for omni models, tracking multimodal outputs. + + Extends the base RequestState with support for accumulating + multimodal tensor outputs (e.g., images, audio, latents) that + are produced incrementally during generation. + """ + + def __init__( + self, + *args, + **kwargs, + ): + # arrival_time is always passed as a keyword argument by the sole caller + # (from_new_request). The *args fallback via positional index was removed + # because upstream RequestState.__init__ reorders parameters between + # releases, making args[12] fragile. + arrival_time = kwargs.get("arrival_time") + super().__init__(*args, **kwargs) + self.native_text_stats = RequestStateStats(arrival_time=float(arrival_time or 0.0)) + # Omni-specific: multimodal output accumulation + # TODO: mm_type is per-request, not per-key. If a model ever produces + # both audio and latent outputs, the modality type would flip on each + # add_multimodal_tensor() call. Consider tracking per-key modality + # types (e.g. dict[str, str]) for future multi-output models. + self.mm_type: str | None = None + self.mm_accumulated: MultimodalPayload = MultimodalPayload() + + def apply_streaming_update(self, update) -> None: + super().apply_streaming_update(update) + self.native_text_stats.arrival_time = update.arrival_time + + def add_multimodal_tensor(self, payload: Any | None, mm_type: str | None) -> None: + """Accumulate a multimodal tensor payload into the request state. + + Normalizes incoming payloads (dict or raw tensor) into a + MultimodalPayload and merges with any previously accumulated data. + Uses list-based deferred concatenation to avoid O(n²) repeated + torch.cat calls. + """ + if payload is None: + return + try: + if mm_type: + self.mm_type = mm_type.lower() + modality_key = self.mm_type or "hidden" + + incoming = MultimodalPayload.from_raw(payload, modality_key) + if incoming is not None: + self.mm_accumulated = self.mm_accumulated.merged_with(incoming) + except (ValueError, TypeError, RuntimeError): + logger.exception("Error accumulating multimodal tensor") + + def _consolidate_multimodal_tensors(self) -> None: + """Consolidate accumulated tensor lists into single tensors. + + Uses TensorAccumulationStrategy derived from the output modality + to determine concatenation behavior. Metadata values always use + REPLACE (keep latest). + """ + if self.mm_accumulated.is_empty: + return + + try: + modality = OutputModality.from_string(self.mm_type) + except (ValueError, KeyError): + modality = OutputModality.TEXT + strategy = get_accumulation_strategy(modality) + + try: + self.mm_accumulated.consolidate_tensors(strategy) + self.mm_accumulated.consolidate_metadata() + except (RuntimeError, TypeError, KeyError): + logger.exception("Error consolidating multimodal tensors") + + # Override: do not route to pooling-only path; always create completion + # outputs, and attach pooling_result into the CompletionOutput. + def make_request_output( + self, + new_token_ids: list[int], + pooling_output: torch.Tensor | None, + finish_reason: FinishReason | None, + stop_reason: int | str | None, + kv_transfer_params: dict[str, Any] | None = None, + routed_experts: Any = None, + ) -> OmniRequestOutput | PoolingRequestOutput | None: + """Create a request output from generation results. + + Creates a RequestOutput or PoolingRequestOutput from the generated + tokens and accumulated multimodal outputs. Attaches multimodal + tensors to the completion output if available. + + Args: + new_token_ids: List of newly generated token IDs + pooling_output: Optional pooling output tensor + finish_reason: Optional finish reason indicating why generation stopped + stop_reason: Optional stop reason (token ID or stop string) + kv_transfer_params: Optional KV cache transfer parameters + routed_experts: Optional MoE routed-expert ids for this step, + attached to the completion output for generation stages + + Returns: + OmniRequestOutput or PoolingRequestOutput if output should be + emitted (based on finish status and output kind), None otherwise + """ + # Pooling-only requests should follow base behavior. + if self.detokenizer is None and pooling_output is not None: + return super().make_request_output( + new_token_ids, + pooling_output, + finish_reason, + stop_reason, + kv_transfer_params, + ) + + finished = finish_reason is not None + final_only = self.output_kind == RequestOutputKind.FINAL_ONLY + is_delta = self.output_kind == RequestOutputKind.DELTA + + if not finished and final_only: + return None + + # Consolidate accumulated tensors when finishing, or for every + # CUMULATIVE step so the consumer always sees a single tensor. + if finished or not is_delta: + self._consolidate_multimodal_tensors() + + if self.stream_interval > 1 and self.detokenizer is not None: + # Send output request only when + # 1. It has finished, or + # 2. It is the first token, or + # 3. It has reached the stream interval number of tokens + if not ( + finished + or self.sent_tokens_offset == 0 + or self.detokenizer.num_output_tokens() - self.sent_tokens_offset >= self.stream_interval + ): + return None + + if self.output_kind == RequestOutputKind.DELTA: + # Send tokens from the offset in DELTA mode, otherwise all + # tokens are sent. + new_token_ids = self.detokenizer.output_token_ids[self.sent_tokens_offset :] + self.sent_tokens_offset = self.detokenizer.num_output_tokens() + + external_req_id = self.external_req_id + + output = self._new_completion_output(new_token_ids, finish_reason, stop_reason, routed_experts) + + if self.parent_req is None: + outputs = [output] + else: + outputs, finished = self.parent_req.get_outputs(self.request_id, output) + if not outputs: + return None + external_req_id = self.parent_req.external_req_id + + return self._new_request_output( + external_req_id, + outputs, + finished, + kv_transfer_params, + ) + + def _new_completion_output( + self, + token_ids: list[int], + finish_reason: FinishReason | None, + stop_reason: int | str | None, + routed_experts: Any = None, + ) -> MultimodalCompletionOutput | CompletionOutput: + """Create a completion output with multimodal data attached. + + Returns a MultimodalCompletionOutput when multimodal data has been + accumulated, otherwise returns the base CompletionOutput. Snapshots + the accumulated payload before draining DELTA-mode modality keys so + that callers receive an immutable view. + """ + # When there is no detokenizer (generation stages), build a minimal + # CompletionOutput directly since upstream asserts detokenizer != None. + if self.detokenizer is None: + finished = finish_reason is not None + base_output = CompletionOutput( + index=self.request_index, + text="", + token_ids=token_ids, + logprobs=None, + cumulative_logprob=None, + finish_reason=str(finish_reason) if finished else None, + stop_reason=stop_reason if finished else None, + routed_experts=routed_experts, + ) + else: + base_output = super()._new_completion_output(token_ids, finish_reason, stop_reason) + + # Always provide cumulative token IDs for inter-stage processors. + if self.detokenizer is not None: + base_output.cumulative_token_ids = list(self.detokenizer.output_token_ids) + else: + base_output.cumulative_token_ids = list(token_ids) + + # Attach cumulative_text only at the final step for inter-stage use. + if finish_reason is not None and hasattr(self.detokenizer, "output_text"): + base_output.cumulative_text = self.detokenizer.output_text + + try: + if self.mm_accumulated and not self.mm_accumulated.is_empty: + # Snapshot: copy current tensors/metadata so drain doesn't + # mutate the payload already handed to the caller. + # Unflatten dotted keys (e.g. "hidden_states.layer_0") back + # to nested dicts so downstream consumers (thinker2talker etc.) + # can access with .get("hidden_states", {}).get("layers", {}). + merged = {**self.mm_accumulated.tensors, **self.mm_accumulated.metadata} + nested = unflatten_payload(merged) + snapshot = MultimodalPayload.from_dict(nested) or MultimodalPayload() + kwargs = {f.name: getattr(base_output, f.name) for f in dataclass_fields(CompletionOutput)} + output = MultimodalCompletionOutput( + multimodal_output=snapshot, + **kwargs, + ) + output.cumulative_token_ids = base_output.cumulative_token_ids + if hasattr(base_output, "cumulative_text"): + output.cumulative_text = base_output.cumulative_text + + # DELTA mode: drain modality keys (e.g. audio) so the next + # step only sees freshly accumulated data for those keys. + if self.output_kind == RequestOutputKind.DELTA: + for modality_key in DRAINABLE_MODALITIES: + self.mm_accumulated.tensors.pop(modality_key, None) + + return output + except (RuntimeError, TypeError, AttributeError): + logger.exception("Error creating MultimodalCompletionOutput") + return base_output + + def _new_request_output( + self, + external_req_id: str, + outputs: list, + finished: bool, + kv_transfer_params: dict[str, Any] | None = None, + ) -> RequestOutput | PoolingRequestOutput: + """Create request output, handling no-detokenizer generation stages. + + Upstream asserts ``self.logprobs_processor is not None`` which fails + for generation stages (talker, code2wav) that have no tokenizer. + When the logprobs processor is absent we build the RequestOutput + directly with ``prompt_logprobs=None``. + """ + if self.logprobs_processor is not None: + return super()._new_request_output( + external_req_id, + outputs, + finished, + kv_transfer_params, + ) + + # No-detokenizer path: build RequestOutput directly. + prompt_token_ids = self.prompt_token_ids + if prompt_token_ids is None and self.prompt_embeds is not None: + prompt_token_ids = [0] * len(self.prompt_embeds) + if prompt_token_ids is None: + prompt_token_ids = [] + + return RequestOutput( + request_id=external_req_id, + lora_request=self.lora_request, + prompt=self.prompt, + prompt_token_ids=prompt_token_ids, + prompt_logprobs=None, + outputs=outputs, + finished=finished, + kv_transfer_params=kv_transfer_params, + num_cached_tokens=self.num_cached_tokens, + metrics=self.stats, + ) + + +class MultimodalOutputProcessor(VLLMOutputProcessor): + """Handles multimodal output processing. + + Captures multimodal outputs from OmniEngineCoreOutput and accumulates + them as MultimodalPayload in OmniRequestState, before delegating to + the base vLLM OutputProcessor for text handling. + + The data flow is: + 1. For each EngineCoreOutput with multimodal_output: + - Capture into OmniRequestState.add_multimodal_tensor() + 2. Base vLLM OutputProcessor handles text detokenization + 3. On finish, _consolidate_multimodal_tensors() concatenates accumulated + tensors using strategy-based dispatch + 4. _new_completion_output() returns MultimodalCompletionOutput + """ + + def __init__( + self, + tokenizer: TokenizerLike | None, + *, + log_stats: bool, + stream_interval: int = 1, + tracing_enabled: bool = False, + engine_core_output_type: str | None = None, + output_modality: OutputModality = OutputModality.TEXT, + ): + """Initialize the multimodal output processor. + + Args: + tokenizer: Tokenizer for detokenizing text outputs + log_stats: Whether to log statistics + stream_interval: Stream interval for output generation + engine_core_output_type: Optional output type string (e.g., + "image", "audio", "latent"). Converted to OutputModality + internally. Kept for backward compatibility with + stage_init_utils. + output_modality: Type-safe output modality flag. Used to tag + multimodal outputs with the correct modality key when + per-output type info is unavailable. + """ + super().__init__( + tokenizer=tokenizer, + log_stats=log_stats, + stream_interval=stream_interval, + tracing_enabled=tracing_enabled, + ) + # Convert string-based engine_core_output_type to OutputModality + if engine_core_output_type is not None: + self.output_modality = OutputModality.from_string(engine_core_output_type) + else: + self.output_modality = output_modality + self.engine_core_output_type = engine_core_output_type + self._native_text_metrics_by_request: dict[str, dict[str, Any]] = {} + + def _native_text_metric_record(self, request_id: str) -> dict[str, Any]: + return self._native_text_metrics_by_request.setdefault( + request_id, + { + "vllm_ttft_ms": 0.0, + "vllm_tpot_ms": 0.0, + "vllm_itl_ms": 0.0, + "vllm_itls_ms": [], + }, + ) + + def pop_native_text_metrics(self, request_id: str) -> dict[str, Any]: + return self._native_text_metrics_by_request.pop(request_id, {}) + + def abort_requests(self, request_ids, internal: bool) -> list[str]: + request_ids = list(request_ids) + for request_id in request_ids: + if internal: + req_state = self.request_states.get(request_id) + if req_state is not None: + self._native_text_metrics_by_request.pop(req_state.external_req_id, None) + else: + self._native_text_metrics_by_request.pop(request_id, None) + return super().abort_requests(request_ids, internal) + + def add_request( + self, + request: EngineCoreRequest, + prompt: str | None, + parent_req: ParentRequest | None = None, + request_index: int = 0, + queue: RequestOutputCollector | None = None, + ) -> None: + """Add a new request to be processed. + + Creates an OmniRequestState for the request and registers it + for output processing. + + Args: + request: Engine core request to add + prompt: Optional prompt string for the request + parent_req: Optional parent request for parallel sampling + request_index: Index of the request in the batch + queue: Optional queue for collecting outputs + + Raises: + ValueError: If the request ID is already registered + """ + request_id = request.request_id + req_state = self.request_states.get(request_id) + if req_state is not None: + self._update_streaming_request_state(req_state, request, prompt) + return + + req_state = OmniRequestState.from_new_request( + tokenizer=self.tokenizer, + request=request, + prompt=prompt, + parent_req=parent_req, + request_index=request_index, + queue=queue, + log_stats=self.log_stats, + stream_interval=self.stream_interval, + ) + self.request_states[request_id] = req_state + if parent_req: + self.parent_requests[parent_req.request_id] = parent_req + self.external_req_ids[req_state.external_req_id].append(request_id) + + def remove_request(self, request_id: str) -> None: + """Rollback one previously registered request if it was never submitted.""" + req_state = self.request_states.pop(request_id, None) + if req_state is None: + return + + external_req_id = getattr(req_state, "external_req_id", None) + if external_req_id is not None: + self._native_text_metrics_by_request.pop(external_req_id, None) + request_ids = self.external_req_ids.get(external_req_id) + if request_ids is not None: + self.external_req_ids[external_req_id] = [rid for rid in request_ids if rid != request_id] + if not self.external_req_ids[external_req_id]: + self.external_req_ids.pop(external_req_id, None) + + parent_req = getattr(req_state, "parent_req", None) + if parent_req is not None: + self.parent_requests.pop(parent_req.request_id, None) + + def process_outputs( + self, + engine_core_outputs: list[EngineCoreOutput], + engine_core_timestamp: float | None = None, + iteration_stats: IterationStats | None = None, + ) -> OutputProcessorOutput: + default_mm_type = _modality_to_type_string(self.output_modality) + + # Separate outputs that upstream can handle (has detokenizer or + # pooling) from multimodal-only outputs (no detokenizer, no pooling) + # that would trigger upstream's `assert detokenizer is not None`. + upstream_outputs: list[EngineCoreOutput] = [] + mm_only_outputs: list[EngineCoreOutput] = [] + + for eco in engine_core_outputs: + req_state = self.request_states.get(eco.request_id) + if req_state is None: + continue + + # Accumulate multimodal tensors regardless of path. + if isinstance(req_state, OmniRequestState): + mm_output = getattr(eco, "multimodal_output", None) + if mm_output is not None: + mm_type = getattr(eco, "output_type", None) or default_mm_type + req_state.add_multimodal_tensor(mm_output, mm_type) + + # Route: if no detokenizer and no pooling output, handle locally + # to avoid upstream's assert on detokenizer. + if req_state.detokenizer is None and eco.pooling_output is None: + mm_only_outputs.append(eco) + else: + upstream_outputs.append(eco) + + # Handle multimodal-only outputs (generation stages) locally. + self._process_mm_only_outputs(mm_only_outputs) + + # Delegate text/pooling outputs to upstream. + return super().process_outputs( + upstream_outputs, + engine_core_timestamp=engine_core_timestamp, + iteration_stats=iteration_stats, + ) + + def _process_mm_only_outputs( + self, + engine_core_outputs: list[EngineCoreOutput], + ) -> None: + """Handle outputs from generation stages that have no detokenizer. + + These cannot go through upstream process_outputs because it asserts + detokenizer is not None when pooling_output is None. + """ + for eco in engine_core_outputs: + req_state = self.request_states.get(eco.request_id) + if req_state is None or not isinstance(req_state, OmniRequestState): + continue + + new_token_ids = eco.new_token_ids + finish_reason = eco.finish_reason + stop_reason = eco.stop_reason + kv_transfer_params = eco.kv_transfer_params + routed_experts = eco.routed_experts + req_state.num_cached_tokens = eco.num_cached_tokens + req_state.is_prefilling = False + + if request_output := req_state.make_request_output( + new_token_ids, + None, # pooling_output + finish_reason, + stop_reason, + kv_transfer_params, + routed_experts, + ): + if req_state.queue is not None: + req_state.queue.put(request_output) + + if finish_reason is not None: + self._finish_request(req_state) + + def _update_stats_from_output( + self, + req_state: RequestState, + engine_core_output: EngineCoreOutput, + engine_core_timestamp: float | None, + iteration_stats: IterationStats | None, + ): + was_prefilling = req_state.is_prefilling + native_stats = req_state.native_text_stats if isinstance(req_state, OmniRequestState) else None + previous_last_token_ts = native_stats.last_token_ts if native_stats is not None else 0.0 + + # NOTE: We pass ``None`` for *iteration_stats* to the parent so that + # the upstream's ``_update_stats_from_output`` logs stats via its own + # ``RequestStateStats`` (req_state.stats) without touching the omni + # ``IterationStats`` object. Below we re-apply the same update using + # *native_stats* (the omni-specific ``RequestStateStats`` attached to + # ``OmniRequestState``), which correctly feeds the omni metrics path. + # If the upstream adds new logic in ``_update_stats_from_output`` (e.g. + # additional stat fields), this override must be kept in sync. + super()._update_stats_from_output( + req_state, + engine_core_output, + engine_core_timestamp, + None, + ) + + if iteration_stats is None or engine_core_timestamp is None or native_stats is None: + return + + iteration_stats.update_from_output( + engine_core_output, + engine_core_timestamp, + was_prefilling, + native_stats, + self.lora_states, + req_state.lora_name, + ) + record = self._native_text_metric_record(req_state.external_req_id) + if was_prefilling: + record["vllm_ttft_ms"] = max(float(native_stats.first_token_latency) * 1000.0, 0.0) + return + + if previous_last_token_ts > 0: + itl_ms = max((engine_core_timestamp - previous_last_token_ts) * 1000.0, 0.0) + itls_ms = record.setdefault("vllm_itls_ms", []) + itls_ms.append(itl_ms) + record["vllm_itl_ms"] = sum(itls_ms) / float(len(itls_ms)) + + def _update_stats_from_finished( + self, + req_state: RequestState, + finish_reason: FinishReason | None, + iteration_stats: IterationStats | None, + ): + super()._update_stats_from_finished(req_state, finish_reason, None) + + native_stats = req_state.native_text_stats if isinstance(req_state, OmniRequestState) else None + if iteration_stats is None or native_stats is None or finish_reason is None: + return + + iteration_stats.update_from_finished_request( + finish_reason=finish_reason, + request_id=req_state.external_req_id, + num_prompt_tokens=req_state.prompt_len, + max_tokens_param=req_state.max_tokens_param, + req_stats=native_stats, + num_cached_tokens=req_state.num_cached_tokens, + ) + if not iteration_stats.finished_requests: + return + + finished_request = iteration_stats.finished_requests[-1] + if finished_request.request_id != req_state.external_req_id: + return + self._native_text_metric_record(req_state.external_req_id)["vllm_tpot_ms"] = ( + float(finished_request.mean_time_per_output_token) * 1000.0 + ) diff --git a/vllm_omni/outputs/utils.py b/vllm_omni/outputs/utils.py new file mode 100644 index 000000000..88fc11fb5 --- /dev/null +++ b/vllm_omni/outputs/utils.py @@ -0,0 +1,24 @@ +"""Shared helpers for multimodal output handling.""" + +from __future__ import annotations + +from typing import Any + +import torch + + +def _to_cpu(x: Any) -> Any: + """Move a tensor to CPU; pass through non-tensors unchanged.""" + if isinstance(x, torch.Tensor): + try: + return x.detach().to("cpu", non_blocking=True).contiguous() + except (RuntimeError, AttributeError): + return x + if isinstance(x, dict): + return {str(sub_key): _to_cpu(sub_value) for sub_key, sub_value in x.items()} + return x + + +def _is_tensor_list(value: Any) -> bool: + """Return True if *value* is a non-empty list of tensors (deferred concat).""" + return isinstance(value, list) and bool(value) and isinstance(value[0], torch.Tensor) From 238fc0a609311235a671940cf209a7eb72c1dc29 Mon Sep 17 00:00:00 2001 From: zhumingjue138 <zhumingjue@huawei.com> Date: Thu, 16 Jul 2026 10:39:44 +0800 Subject: [PATCH 091/101] [Bugfix] fix issue 5065 (#5118) --- tests/model_tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/model_tests/conftest.py b/tests/model_tests/conftest.py index 502f3e685..ab71a02cd 100644 --- a/tests/model_tests/conftest.py +++ b/tests/model_tests/conftest.py @@ -22,7 +22,7 @@ def tiny_model_paths(request, run_level): if not hasattr(item, "callspec"): continue model_name = item.callspec.params.get("model_name") - if model_name is None: + if model_name is None or model_name not in DIFFUSION_TEST_SETTINGS: continue if model_name not in model_paths: settings = DIFFUSION_TEST_SETTINGS[model_name] From 68a992060b34e9617da6311416a3eafce61d2214 Mon Sep 17 00:00:00 2001 From: Ricardo Noriega <rnoriega@redhat.com> Date: Thu, 16 Jul 2026 08:17:02 +0200 Subject: [PATCH 092/101] [Bugfix] Fix audio.format parameter silently ignored in chat completions (#4718) Signed-off-by: Ricardo Noriega De Soto <rnoriega@redhat.com> --- docs/serving/audio_generate_api.md | 5 +- docs/serving/speech_api.md | 2 +- .../examples/online_serving/text_to_audio.md | 2 +- .../test_qwen3_omni_expansion.py | 19 ++ .../openai_api/test_audio_format.py | 175 ++++++++++++++++++ .../entrypoints/openai/audio_utils_mixin.py | 7 +- .../entrypoints/openai/protocol/audio.py | 14 +- vllm_omni/entrypoints/openai/serving_chat.py | 39 +++- 8 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 tests/entrypoints/openai_api/test_audio_format.py diff --git a/docs/serving/audio_generate_api.md b/docs/serving/audio_generate_api.md index afe693ae4..3109446c1 100644 --- a/docs/serving/audio_generate_api.md +++ b/docs/serving/audio_generate_api.md @@ -66,7 +66,7 @@ Content-Type: application/json |-----------|------|---------|-------------| | `input` | string | **required** | Text prompt describing the audio to generate | | `model` | string | server's model | Model to use (optional, should match server if specified) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25 - 4.0) | #### Diffusion Parameters @@ -90,7 +90,6 @@ Returns binary audio data with the appropriate `Content-Type` header: | `mp3` | `audio/mpeg` | | `flac` | `audio/flac` | | `pcm` | `audio/pcm` | -| `aac` | `audio/aac` | | `opus` | `audio/opus` | ## Examples @@ -285,7 +284,7 @@ Pydantic validation failure (e.g. invalid `response_format`, `speed` out of rang "detail": [ { "type": "literal_error", - "msg": "Input should be 'wav', 'pcm', 'flac', 'mp3', 'aac' or 'opus'", + "msg": "Input should be 'wav', 'pcm', 'flac', 'mp3' or 'opus'", ... } ] diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index 27dfda95f..2bf630476 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -109,7 +109,7 @@ Content-Type: application/json | `input` | string | **required** | The text to synthesize into speech | | `model` | string | server's model | Model to use (optional, should match server if specified) | | `voice` | string | "vivian" | Speaker name (e.g., vivian, ryan, aiden) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25-4.0) | #### vLLM-Omni Extension Parameters diff --git a/docs/user_guide/examples/online_serving/text_to_audio.md b/docs/user_guide/examples/online_serving/text_to_audio.md index b1ec33a20..a4833aef6 100644 --- a/docs/user_guide/examples/online_serving/text_to_audio.md +++ b/docs/user_guide/examples/online_serving/text_to_audio.md @@ -139,7 +139,7 @@ Content-Type: application/json |-----------|------|---------|-------------| | `input` | string | **required** | Text prompt describing the audio to generate | | `model` | string | server's model | Model to use (optional, should match server if specified) | -| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, opus | | `speed` | float | 1.0 | Playback speed (0.25 - 4.0) | | `audio_length` | float | null | Audio duration in seconds (max ~47s for `stable-audio-open-1.0`) | | `audio_start` | float | 0.0 | Audio start time in seconds | diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py index 25ba4f284..3ded285ce 100644 --- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py +++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py @@ -7,6 +7,7 @@ E2E Online tests for Qwen3-Omni model. import os import pytest +from openai import BadRequestError from tests.helpers.mark import hardware_test from tests.helpers.media import generate_synthetic_audio, generate_synthetic_image, generate_synthetic_video @@ -610,3 +611,21 @@ def test_text_to_audio_long_output_001(omni_server, openai_client) -> None: text = responses[0].text_content if responses else "" word_count = len(text.split()) assert word_count >= 200, f"Expected at least 200 words in long output, got {word_count}" + + +@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) +@pytest.mark.parametrize("omni_server", test_params[:1], indirect=True) +def test_invalid_audio_format_rejected(omni_server, openai_client) -> None: + """Ensure invalid audio format is rejected with 400 before streaming starts.""" + messages = dummy_messages_from_mix_data( + system_prompt=get_system_prompt(), + content_text=get_prompt(), + ) + with pytest.raises(BadRequestError, match="audio format"): + openai_client.client.chat.completions.create( + model=omni_server.model, + messages=messages, + modalities=["text", "audio"], + audio={"voice": "alloy", "format": "aac"}, + stream=True, + ) diff --git a/tests/entrypoints/openai_api/test_audio_format.py b/tests/entrypoints/openai_api/test_audio_format.py new file mode 100644 index 000000000..2f1f487d9 --- /dev/null +++ b/tests/entrypoints/openai_api/test_audio_format.py @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for audio output format handling in chat completions. + +Covers: +- #4716: audio.format parameter must be respected, not hardcoded to WAV +- Format validation rejects unsupported formats +- pcm16 is mapped to pcm for soundfile compatibility +- Default format is WAV when not specified +- create_audio encodes correctly for each supported format +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin +from vllm_omni.entrypoints.openai.protocol.audio import ( + DEFAULT_AUDIO_FORMAT, + SUPPORTED_AUDIO_FORMATS, + SUPPORTED_CHAT_AUDIO_FORMATS, + CreateAudio, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestAudioFormatConstants: + def test_default_format_is_wav(self): + assert DEFAULT_AUDIO_FORMAT == "wav" + + def test_supported_formats_no_aac(self): + assert "aac" not in SUPPORTED_AUDIO_FORMATS + + def test_chat_formats_include_pcm16(self): + assert "pcm16" in SUPPORTED_CHAT_AUDIO_FORMATS + + def test_chat_formats_superset_of_audio_formats(self): + assert SUPPORTED_AUDIO_FORMATS <= SUPPORTED_CHAT_AUDIO_FORMATS + + +class TestCreateAudio: + @pytest.fixture + def mixin(self): + return AudioMixin() + + @pytest.fixture + def audio_tensor(self): + return np.sin(np.linspace(0, 2 * np.pi, 24000)).astype(np.float32) + + @pytest.mark.parametrize("fmt", ["wav", "mp3", "flac", "opus", "pcm"]) + def test_create_audio_supported_formats(self, mixin, audio_tensor, fmt): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format=fmt, + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert len(response.audio_data) > 0 + + def test_wav_magic_bytes(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="wav", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"RIFF" + assert response.media_type == "audio/wav" + + def test_mp3_encoding(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="mp3", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.media_type == "audio/mpeg" + assert response.audio_data[:4] != b"RIFF" + + def test_flac_magic_bytes(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="flac", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"fLaC" + assert response.media_type == "audio/flac" + + def test_unsupported_format_falls_back_to_default(self, mixin, audio_tensor): + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="aac", + speed=1.0, + base64_encode=False, + ) + response = mixin.create_audio(audio_obj) + assert response.audio_data[:4] == b"RIFF" + assert response.media_type == "audio/wav" + + def test_base64_encoding(self, mixin, audio_tensor): + import base64 + + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=24000, + response_format="wav", + speed=1.0, + base64_encode=True, + ) + response = mixin.create_audio(audio_obj) + decoded = base64.b64decode(response.audio_data) + assert decoded[:4] == b"RIFF" + + +class TestResolveAudioFormat: + """Test _resolve_audio_format via the serving chat class.""" + + @pytest.fixture + def serving_chat(self): + from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat + + return object.__new__(OmniOpenAIServingChat) + + def _make_request(self, audio_params=None): + from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest + + req = ChatCompletionRequest( + model="test-model", + messages=[{"role": "user", "content": "hello"}], + ) + if audio_params is not None: + req.audio = audio_params + return req + + def test_default_format_when_no_audio_params(self, serving_chat): + request = self._make_request() + result = serving_chat._resolve_audio_format(request) + assert result == "wav" + + def test_extracts_mp3_format(self, serving_chat): + request = self._make_request({"format": "mp3", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert result == "mp3" + + def test_pcm16_mapped_to_pcm(self, serving_chat): + request = self._make_request({"format": "pcm16", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert result == "pcm" + + def test_invalid_format_returns_error(self, serving_chat): + from vllm.entrypoints.openai.engine.protocol import ErrorResponse + + request = self._make_request({"format": "aac", "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert isinstance(result, ErrorResponse) + assert "aac" in result.error.message + + def test_all_supported_formats_accepted(self, serving_chat): + from vllm.entrypoints.openai.engine.protocol import ErrorResponse + + for fmt in SUPPORTED_CHAT_AUDIO_FORMATS: + request = self._make_request({"format": fmt, "voice": "alloy"}) + result = serving_chat._resolve_audio_format(request) + assert not isinstance(result, ErrorResponse), f"Format {fmt} should be accepted" diff --git a/vllm_omni/entrypoints/openai/audio_utils_mixin.py b/vllm_omni/entrypoints/openai/audio_utils_mixin.py index 12024013d..6c7043201 100644 --- a/vllm_omni/entrypoints/openai/audio_utils_mixin.py +++ b/vllm_omni/entrypoints/openai/audio_utils_mixin.py @@ -5,7 +5,7 @@ import torch import torchaudio from vllm.logger import init_logger -from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio +from vllm_omni.entrypoints.openai.protocol.audio import DEFAULT_AUDIO_FORMAT, AudioResponse, CreateAudio try: import soundfile @@ -49,13 +49,12 @@ class AudioMixin: "pcm": ("RAW", "audio/pcm", {"subtype": "PCM_16"}), "flac": ("FLAC", "audio/flac", {}), "mp3": ("MP3", "audio/mpeg", {}), - "aac": ("AAC", "audio/aac", {}), "opus": ("OGG", "audio/ogg", {"subtype": "OPUS"}), } if response_format not in supported_formats: - logger.warning(f"Unsupported response format '{response_format}', defaulting to 'wav'.") - response_format = "wav" + logger.warning(f"Unsupported response format '{response_format}', defaulting to '{DEFAULT_AUDIO_FORMAT}'.") + response_format = DEFAULT_AUDIO_FORMAT soundfile_format, media_type, kwargs = supported_formats[response_format] diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index ad7bf8fae..92b2efd14 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -6,6 +6,10 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_vali _MAX_EMBEDDING_DIM = 8192 +SUPPORTED_AUDIO_FORMATS: frozenset[str] = frozenset({"wav", "pcm", "flac", "mp3", "opus"}) +SUPPORTED_CHAT_AUDIO_FORMATS: frozenset[str] = SUPPORTED_AUDIO_FORMATS | {"pcm16"} +DEFAULT_AUDIO_FORMAT: str = "wav" + def _normalize_ref_audio_value(value): if value is None: @@ -59,7 +63,7 @@ class OpenAICreateSpeechRequest(BaseModel): default=None, description="Instructions for voice style/emotion (maps to 'instruct' for Qwen3-TTS)", ) - response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav" + response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT speed: float | None = Field( default=1.0, ge=0.25, @@ -325,7 +329,7 @@ class OpenAICreateAudioGenerateRequest(BaseModel): description="Text prompt describing the audio to generate", ) model: str | None = None - response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav" + response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT speed: float | None = Field( default=1.0, ge=0.25, @@ -391,7 +395,7 @@ class SpeechBatchItem(BaseModel): input: str voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker")) instructions: str | None = None - response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] | None = None + response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] | None = None speed: float | None = Field(default=None, ge=0.25, le=4.0) task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None language: str | None = None @@ -411,7 +415,7 @@ class BatchSpeechRequest(BaseModel): items: list[SpeechBatchItem] = Field(..., min_length=1) voice: str | None = Field(default=None, validation_alias=AliasChoices("voice", "speaker")) instructions: str | None = None - response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav" + response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT speed: float | None = Field(default=1.0, ge=0.25, le=4.0) task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None language: str | None = None @@ -500,7 +504,7 @@ class StreamingSpeechSessionConfig(BaseModel): task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None language: str | None = None instructions: str | None = None - response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav" + response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT speed: float | None = Field(default=1.0, ge=0.25, le=4.0) max_new_tokens: int | None = Field(default=None, ge=1) initial_codec_chunk_frames: int | None = Field( diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py index a1a51c303..eec6a03b9 100755 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -97,7 +97,12 @@ from vllm.v1.engine.exceptions import EngineDeadError from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.image_api_utils import encode_image_base64_with_compression, validate_layered_layers from vllm_omni.entrypoints.openai.protocol import OmniChatCompletionStreamResponse -from vllm_omni.entrypoints.openai.protocol.audio import AudioResponse, CreateAudio +from vllm_omni.entrypoints.openai.protocol.audio import ( + DEFAULT_AUDIO_FORMAT, + SUPPORTED_CHAT_AUDIO_FORMATS, + AudioResponse, + CreateAudio, +) from vllm_omni.entrypoints.openai.protocol.images import ( ImageData, ImageEditARDeltaChunk, @@ -450,6 +455,11 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): output_modalities = getattr(request, "modalities", engine_output_modalities) request.modalities = output_modalities if output_modalities is not None else engine_output_modalities + if request.modalities and "audio" in request.modalities: + audio_format_check = self._resolve_audio_format(request) + if isinstance(audio_format_check, ErrorResponse): + return audio_format_check + num_inference_steps = None extra_body: dict[str, Any] = {} # Omni multistage image generation: Stage-0 (AR) should receive a clean @@ -2561,10 +2571,14 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): else: sample_rate = int(sr_raw) + audio_format = self._resolve_audio_format(request) + if isinstance(audio_format, ErrorResponse): + return audio_format + audio_obj = CreateAudio( audio_tensor=audio_tensor, sample_rate=sample_rate, - response_format="wav", + response_format=audio_format, speed=1.0, base64_encode=True, ) @@ -3515,10 +3529,14 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): raise ValueError(f"Unexpected audio tensor rank {audio_tensor.ndim}; expected 1-3 dims.") audio_array = audio_tensor.numpy() + audio_format = self._resolve_audio_format(request) + if isinstance(audio_format, ErrorResponse): + return audio_format + audio_obj = CreateAudio( audio_tensor=audio_array, sample_rate=sample_rate, - response_format="wav", + response_format=audio_format, speed=1.0, base64_encode=True, ) @@ -3734,6 +3752,21 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): return height, width + def _resolve_audio_format(self, request: ChatCompletionRequest) -> str | ErrorResponse: + """Extract and validate the audio output format from a chat completion request.""" + audio_params = getattr(request, "audio", None) + if isinstance(audio_params, dict): + audio_format = audio_params.get("format", DEFAULT_AUDIO_FORMAT) + else: + audio_format = DEFAULT_AUDIO_FORMAT + if audio_format not in SUPPORTED_CHAT_AUDIO_FORMATS: + return self._create_error_response( + f"Invalid audio format '{audio_format}'. Supported formats: {sorted(SUPPORTED_CHAT_AUDIO_FORMATS)}", + ) + if audio_format == "pcm16": + audio_format = "pcm" + return audio_format + def _create_error_response( self, message: str, From 5c3900960bc0d500c05619f02ee99211ecb81d73 Mon Sep 17 00:00:00 2001 From: Canlin Guo <canlinguosdu@gmail.com> Date: Thu, 16 Jul 2026 15:02:15 +0800 Subject: [PATCH 093/101] [Feature] Support MOSS-TTS-Local-v1.5 vocoder graph (#4929) Signed-off-by: gcanlin <canlinguosdu@gmail.com> Signed-off-by: sunnyxyli <1550693033@qq.com> Co-authored-by: sunnyxyli <1550693033@qq.com> --- .../benchmarks/data_modules/seed_tts_eval.py | 7 +- .../cuda_graph_streaming_decoder_wrapper.py | 164 ++++++++++++++++++ .../moss_tts/modeling_moss_tts_codec.py | 113 ++++++++++-- .../models/moss_tts/moss_codec_cudagraph.py | 19 +- .../worker/gpu_generation_model_runner.py | 11 +- 5 files changed, 283 insertions(+), 31 deletions(-) create mode 100644 vllm_omni/model_executor/models/moss_tts/cuda_graph_streaming_decoder_wrapper.py diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py index 70ed7cfef..13c1f2c21 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py @@ -613,7 +613,12 @@ def compute_seed_tts_wer_metrics( e, ) - wav_16k = _pcm_s16le_to_f32_16k(pcm) + # Request functions normalize ``tts_output_pcm_bytes`` to Seed-TTS WER + # format before it reaches this evaluator: 24 kHz mono int16 PCM. + # Do not apply the streamed-response env format here again; for MOSS + # local that would reinterpret already-normalized 24 kHz mono audio as + # 48 kHz stereo and corrupt ASR/WER. + wav_16k = _pcm_s16le_to_f32_16k(pcm, pcm_sample_rate=24000, channels=1) if len(wav_16k) == 0: asr_failed += 1 if include_per_item: diff --git a/vllm_omni/model_executor/models/moss_tts/cuda_graph_streaming_decoder_wrapper.py b/vllm_omni/model_executor/models/moss_tts/cuda_graph_streaming_decoder_wrapper.py new file mode 100644 index 000000000..1daa5081e --- /dev/null +++ b/vllm_omni/model_executor/models/moss_tts/cuda_graph_streaming_decoder_wrapper.py @@ -0,0 +1,164 @@ +# Copyright 2026 OpenMOSS and the vLLM-Omni team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). +"""CUDA graph wrapper for MOSS-TTS Local streaming codec decode.""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass + +import torch +import torch.nn as nn +from torch.cuda import CUDAGraph +from vllm.logger import init_logger +from vllm.platforms import current_platform + +logger = init_logger(__name__) + + +@dataclass +class _CapturedStreamingDecodeGraph: + graph: CUDAGraph + static_codes: torch.Tensor + static_lengths: torch.Tensor + static_audio: torch.Tensor + static_audio_lengths: torch.Tensor + + +class CUDAGraphStreamingDecoderWrapper: + """Replay fixed-size streaming ``_decode_frame`` steps with CUDA graphs. + + The wrapped codec must already be inside ``codec.streaming(batch_size)``. + Graphs are keyed by the streaming step frame count ``T``. The batch width is + fixed to the codec stream slot count. + """ + + def __init__( + self, + codec: nn.Module, + *, + batch_size: int, + num_quantizers: int, + reset_streaming_state: Callable[[], None] | None = None, + ) -> None: + self.codec = codec + self.batch_size = int(batch_size) + self.num_quantizers = int(num_quantizers) + self.reset_streaming_state = reset_streaming_state + self.graphs: dict[int, _CapturedStreamingDecodeGraph] = {} + self._pool = None + self._warmed_up = False + + @property + def is_ready(self) -> bool: + return bool(self.graphs) + + @property + def capture_sizes(self) -> list[int]: + return sorted(self.graphs) + + @torch.no_grad() + def warmup(self, device: torch.device, capture_sizes: list[int]) -> None: + if self._warmed_up: + return + self._warmed_up = True + if device.type != "cuda": + return + sizes = sorted({int(size) for size in capture_sizes if int(size) > 0}, reverse=True) + if not sizes: + return + + logger.info( + "MOSS-TTS streaming decoder CUDA graph warmup: batch_size=%d n_vq=%d capture_sizes=%s", + self.batch_size, + self.num_quantizers, + list(reversed(sizes)), + ) + start_s = time.perf_counter() + with torch.cuda.device(device): + for size in sizes: + try: + self._capture(size, device) + logger.info(" Captured MOSS-TTS streaming decoder CUDA graph for size=%d", size) + except Exception: + self.graphs.pop(size, None) + logger.warning( + " Failed to capture MOSS-TTS streaming decoder CUDA graph for size=%d; " + "this size will use eager decode", + size, + exc_info=True, + ) + logger.info( + "MOSS-TTS streaming decoder CUDA graph warmup complete: %d/%d captured in %.1f ms", + len(self.graphs), + len(sizes), + (time.perf_counter() - start_s) * 1000.0, + ) + + @torch.no_grad() + def _capture(self, size: int, device: torch.device) -> None: + codes = torch.zeros( + self.num_quantizers, + self.batch_size, + size, + dtype=torch.long, + device=device, + ) + lengths = torch.full((self.batch_size,), size, dtype=torch.long, device=device) + exec_mask = torch.ones(self.batch_size, dtype=torch.bool, device=device) + + self.codec._set_streaming_exec_mask(exec_mask) + stream = torch.cuda.Stream() + stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(stream): + _ = self.codec._decode_frame(codes, lengths) + torch.cuda.current_stream().wait_stream(stream) + torch.accelerator.synchronize(device) + + if self.reset_streaming_state is not None: + self.reset_streaming_state() + self.codec._set_streaming_exec_mask(exec_mask) + if self._pool is None: + self._pool = current_platform.get_global_graph_pool() + graph = CUDAGraph() + with torch.cuda.graph(graph, pool=self._pool, capture_error_mode="thread_local"): + output = self.codec._decode_frame(codes, lengths) + + if output.audio is None or output.audio_lengths is None: + raise RuntimeError("MOSS-TTS streaming decoder CUDA graph capture produced empty audio.") + + self.graphs[size] = _CapturedStreamingDecodeGraph( + graph=graph, + static_codes=codes, + static_lengths=lengths, + static_audio=output.audio, + static_audio_lengths=output.audio_lengths, + ) + + @torch.no_grad() + def decode( + self, + codes_step: torch.Tensor, + exec_mask: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor] | None: + if not codes_step.is_cuda or torch.cuda.is_current_stream_capturing(): + return None + n_vq, batch_size, step_t = codes_step.shape + if int(n_vq) != self.num_quantizers or int(batch_size) != self.batch_size: + return None + # The streaming codec advances its internal KV offsets by tensor T, so + # bucket-padding a smaller step into a larger captured graph would write + # padded frames into state. Capture exact step sizes instead. + entry = self.graphs.get(int(step_t)) + if entry is None: + return None + + self.codec._set_streaming_exec_mask(exec_mask) + entry.static_codes.copy_(codes_step) + entry.graph.replay() + return entry.static_audio, entry.static_audio_lengths + + +__all__ = ["CUDAGraphStreamingDecoderWrapper"] diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py index 60f455240..8f1a72e9b 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py @@ -26,6 +26,9 @@ from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import ( from vllm_omni.model_executor.models.moss_tts.configuration_moss_audio_tokenizer_v2 import ( MossAudioTokenizerConfig as MossAudioTokenizerV2Config, ) +from vllm_omni.model_executor.models.moss_tts.cuda_graph_streaming_decoder_wrapper import ( + CUDAGraphStreamingDecoderWrapper, +) from vllm_omni.model_executor.models.moss_tts.moss_codec_cudagraph import ( MossTTSCUDAGraphCodecWrapper, ) @@ -43,6 +46,7 @@ class _MossCodecStreamSession: *, stream_slots: int, n_vq: int, + cudagraph_capture_sizes: list[int] | None = None, ) -> None: self._codec = codec self._stream_slots = int(stream_slots) @@ -54,6 +58,19 @@ class _MossCodecStreamSession: self._closed = False with torch.no_grad(): self._exit_stack.enter_context(codec.streaming(self._batch_size)) + self._cudagraph_wrapper: CUDAGraphStreamingDecoderWrapper | None = None + capture_sizes = sorted({int(size) for size in (cudagraph_capture_sizes or []) if int(size) > 0}) + if capture_sizes and self._device.type == "cuda": + self._cudagraph_wrapper = CUDAGraphStreamingDecoderWrapper( + codec, + batch_size=self._batch_size, + num_quantizers=self._n_vq, + reset_streaming_state=lambda: self.reset_slots(list(range(self._batch_size))), + ) + self._cudagraph_wrapper.warmup(self._device, capture_sizes) + self.reset_slots(list(range(self._batch_size))) + if not self._cudagraph_wrapper.is_ready: + self._cudagraph_wrapper = None def acquire(self) -> int | None: if not self._free_stream_slots: @@ -109,12 +126,23 @@ class _MossCodecStreamSession: codes_lengths[slot] = int(codes.shape[1]) exec_mask[slot] = True - self._codec._set_streaming_exec_mask(exec_mask) - result = self._codec._decode_frame(codes_step, codes_lengths) - if result.audio is None: - return {} - audio = result.audio.detach().to("cpu", torch.float32) - lengths = result.audio_lengths.detach().to("cpu") if result.audio_lengths is not None else None + graph_output: tuple[torch.Tensor, torch.Tensor] | None = None + if self._cudagraph_wrapper is not None: + graph_output = self._cudagraph_wrapper.decode(codes_step, exec_mask) + + used_cudagraph = graph_output is not None + if used_cudagraph: + audio_tensor, lengths_tensor = graph_output + else: + self._codec._set_streaming_exec_mask(exec_mask) + result = self._codec._decode_frame(codes_step, codes_lengths) + if result.audio is None: + return {} + audio_tensor = result.audio + lengths_tensor = result.audio_lengths + + audio = audio_tensor.detach().to("cpu", torch.float32) + lengths = lengths_tensor.detach().to("cpu") if lengths_tensor is not None else None out: dict[int, torch.Tensor] = {} for slot in slot_codes: wav = audio[slot] @@ -174,10 +202,17 @@ class MossTTSCodecDecoder(nn.Module): self._sr_tensor = torch.tensor(self._OUTPUT_SAMPLE_RATE, dtype=torch.int32) self._stream_session: _MossCodecStreamSession | None = None self._stream_slots: int = self._connector_int("codec_stream_slots", default=0) - self._stream_max_step_frames: int = self._connector_int("codec_max_step_frames", default=100) + self._stream_chunk_frames: int = self._connector_int("codec_chunk_frames", default=0) + self._stream_max_step_frames: int = self._stream_chunk_frames or 100 self._stream_req_slots: dict[str, int] = {} self._stream_pending_codes: dict[str, list[torch.Tensor]] = {} self._stream_starved_reqs: set[str] = set() + self._codec_streaming: bool = self._connector_bool("codec_streaming", default=False) + default_graph_capture_max = self._stream_chunk_frames or min(self._stream_max_step_frames, 32) + default_graph_capture_sizes = list(range(1, max(1, default_graph_capture_max) + 1)) + self._streaming_cudagraph_capture_sizes = self._streaming_cudagraph_capture_sizes_from_compilation_config( + default_graph_capture_sizes + ) # ------------------------------------------------------------------ # vLLM-Omni stubs (codec has no AR loop) @@ -240,6 +275,15 @@ class MossTTSCodecDecoder(nn.Module): }, ) + if self._codec_streaming and runtime_additional_information is None: + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={ + "model_outputs": [empty] * num_req, + "sr": [sr_tensor] * num_req, + }, + ) + audios: list[torch.Tensor] = [empty] * num_req srs: list[torch.Tensor] = [sr_tensor] * num_req device = next(self._codec.parameters()).device @@ -293,7 +337,7 @@ class MossTTSCodecDecoder(nn.Module): continue meta = (info.get("meta", {}) if isinstance(info, dict) else {}) or {} finished = bool(meta.get("stream_finished", meta.get("finished", False))) - streaming_enabled = bool(meta.get("codec_streaming", False)) + streaming_enabled = bool(meta.get("codec_streaming", self._codec_streaming)) code_flat_numel = meta.get("code_flat_numel") if streaming_enabled and finished and code_flat_numel is not None and int(code_flat_numel) == 0: for _, wav in self._finish_empty_streaming_requests([info]).items(): @@ -382,7 +426,7 @@ class MossTTSCodecDecoder(nn.Module): if not isinstance(info, dict): continue meta = (info.get("meta", {}) or {}) if isinstance(info.get("meta", {}), dict) else {} - if not bool(meta.get("codec_streaming", False)): + if not bool(meta.get("codec_streaming", self._codec_streaming)): continue finished = bool(meta.get("stream_finished", meta.get("finished", False))) if not finished: @@ -446,6 +490,7 @@ class MossTTSCodecDecoder(nn.Module): self._codec, stream_slots=max(1, slots), n_vq=self._n_vq, + cudagraph_capture_sizes=self._streaming_cudagraph_capture_sizes, ) return self._stream_session @@ -597,6 +642,30 @@ class MossTTSCodecDecoder(nn.Module): return int(extra_cfg[name]) return default + def _connector_bool(self, name: str, default: bool = False) -> bool: + model_cfg = getattr(self.vllm_config, "model_config", None) + connector_cfg = getattr(model_cfg, "stage_connector_config", None) + if isinstance(connector_cfg, dict): + extra_cfg: dict | None = connector_cfg.get("extra", connector_cfg) + else: + extra_cfg = getattr(connector_cfg, "extra", None) + if isinstance(extra_cfg, dict) and name in extra_cfg: + return bool(extra_cfg[name]) + return default + + def _streaming_cudagraph_capture_sizes_from_compilation_config( + self, + default: list[int], + ) -> list[int]: + if getattr(self.vllm_config.model_config, "enforce_eager", True): + return [] + compilation_config = getattr(self.vllm_config, "compilation_config", None) + capture_sizes = getattr(compilation_config, "cudagraph_capture_sizes", None) + if capture_sizes: + max_step_frames = max(1, int(self._stream_max_step_frames)) + return sorted({int(size) for size in capture_sizes if 0 < int(size) <= max_step_frames}) + return list(default) + # ------------------------------------------------------------------ # Weight loading # ------------------------------------------------------------------ @@ -716,7 +785,9 @@ class MossTTSCodecDecoder(nn.Module): self._n_channels, ) - self._maybe_enable_decoder_cudagraph(device) + self._configure_decoder_cudagraph(device) + if self._codec_streaming and self._streaming_cudagraph_capture_sizes: + self._ensure_stream_session() # vLLM's track_weights_loading() compares the returned set against # ``self.named_parameters()``. After ``self._codec = codec`` above, @@ -739,12 +810,30 @@ class MossTTSCodecDecoder(nn.Module): codec = MossAudioTokenizerModel(codec_cfg) return codec_cfg, codec - def _maybe_enable_decoder_cudagraph(self, device: torch.device) -> None: - """Capture CUDA Graphs for the codec decoder if enforce_eager is False.""" + def _configure_decoder_cudagraph(self, device: torch.device) -> None: + """Select the codec CUDA Graph path. + + ``enforce_eager`` is the single graph on/off switch. If graphing is + enabled, ``codec_streaming`` decides whether decode uses the persistent + streaming-state wrapper or the offline full-chunk wrapper. + """ if getattr(self.vllm_config.model_config, "enforce_eager", True): + self._streaming_cudagraph_capture_sizes = [] return if self._codec is None: return + if self._codec_streaming: + logger.info( + "MOSS-TTS codec CUDA Graph selected streaming wrapper: capture_sizes=%s", + self._streaming_cudagraph_capture_sizes, + ) + return + + self._enable_non_streaming_decoder_cudagraph(device) + + def _enable_non_streaming_decoder_cudagraph(self, device: torch.device) -> None: + if self._codec is None: + return # Read capture sizes from the connector's extra config (same convention # as Qwen3-TTS), falling back to a sensible default covering common diff --git a/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py b/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py index 71585f1c0..87f255072 100644 --- a/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py +++ b/vllm_omni/model_executor/models/moss_tts/moss_codec_cudagraph.py @@ -1,6 +1,6 @@ """CUDA Graph acceleration for the MOSS Audio Tokenizer codec decoder. -Captures the codec decode method (_decode_frame, or _decode on the vendored codec) for a set of fixed frame-count +Captures MossAudioTokenizerModel._decode_frame for a set of fixed frame-count bucket sizes, then replays the captured graph at inference time to eliminate kernel-launch overhead. Inputs that exceed all captured sizes fall back to eager execution transparently. @@ -24,7 +24,7 @@ logger = init_logger(__name__) class MossTTSCUDAGraphCodecWrapper: - """CUDA Graph wrapper for the codec decode method (_decode_frame, or _decode on the vendored codec). + """CUDA Graph wrapper for MossAudioTokenizerModel._decode_frame. Graphs are keyed by padded_T (int). On each call the actual T is bucket-matched to the smallest pre-captured size >= T. The static code @@ -51,15 +51,6 @@ class MossTTSCUDAGraphCodecWrapper: enabled: bool = True, ) -> None: self.model = model - # Upstream remote-code tokenizer exposes _decode_frame; the vendored - # MossAudioTokenizerModel still exposes _decode. Resolve once here so - # both codec implementations work. - decode_fn = getattr(model, "_decode_frame", None) or getattr(model, "_decode", None) - if decode_fn is None: - raise AttributeError( - f"{type(model).__module__}.{type(model).__name__} exposes neither _decode_frame nor _decode" - ) - self._decode_fn = decode_fn self.capture_sizes: list[int] = sorted(capture_sizes) self.num_quantizers = num_quantizers self.enabled = enabled @@ -108,7 +99,7 @@ class MossTTSCUDAGraphCodecWrapper: dummy_codes = torch.zeros(nq, 1, size, dtype=torch.long, device=device) dummy_lengths = torch.tensor([size], dtype=torch.long, device=device) with torch.no_grad(): - _ = self._decode_fn(dummy_codes, dummy_lengths) + _ = self.model._decode_frame(dummy_codes, dummy_lengths) torch.accelerator.synchronize(device) @@ -141,13 +132,13 @@ class MossTTSCUDAGraphCodecWrapper: # Extra eager warmup inside capture to ensure all kernels are compiled. with torch.no_grad(): - _ = self._decode_fn(static_codes, static_lengths) + _ = self.model._decode_frame(static_codes, static_lengths) torch.accelerator.synchronize(device) graph = CUDAGraph() with torch.no_grad(): with torch.cuda.graph(graph, pool=current_platform.get_global_graph_pool()): - static_out = self._decode_fn(static_codes, static_lengths) + static_out = self.model._decode_frame(static_codes, static_lengths) self.graphs[size] = graph self.static_codes[size] = static_codes diff --git a/vllm_omni/worker/gpu_generation_model_runner.py b/vllm_omni/worker/gpu_generation_model_runner.py index c8fe92eba..ec41900b1 100644 --- a/vllm_omni/worker/gpu_generation_model_runner.py +++ b/vllm_omni/worker/gpu_generation_model_runner.py @@ -76,10 +76,6 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin def _update_request_states(self, scheduler_output: SchedulerOutput): # remove requests - # Some stateful vocoder model may need to clean the state - # to avoid the leak of slots when the requests have been aborted. - if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"): - self.model.on_requests_finished(scheduler_output.finished_req_ids) for req_id in scheduler_output.finished_req_ids: self.input_batch.remove_request(req_id) scheduled_req_ids = scheduler_output.num_scheduled_tokens.keys() @@ -157,6 +153,13 @@ class GPUGenerationModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin if self.model_config.async_chunk and num_scheduled_tokens: self._update_request_states(scheduler_output) deferred_state_corrections_fn = self._update_states(scheduler_output) + + # Notify stateful models of finished requests before the + # zero-token early return. Request cleanup is a model lifecycle + # concern and does not depend on the inter-stage transfer mode. + if scheduler_output.finished_req_ids and hasattr(self.model, "on_requests_finished"): + self.model.on_requests_finished(scheduler_output.finished_req_ids) + if not scheduler_output.total_num_scheduled_tokens: return self.attach_omni_connector_output(EMPTY_MODEL_RUNNER_OUTPUT) From 2a49b2b21722df40efdfaa4a6ce1f4dd5f802d05 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:45:00 +0800 Subject: [PATCH 094/101] =?UTF-8?q?[CI][Perf]=20Add=20JSON=20mark=20suppor?= =?UTF-8?q?t=20and=20paired=20parametrize=20for=20L4=20perf,=20so=20as=20t?= =?UTF-8?q?o=20further=20support=20performance=E2=80=91case=20selection=20?= =?UTF-8?q?for=20NPU=E2=80=91CI.=20(#5084)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: wangyu <410167048@qq.com> Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com> --- .claude/skills/add-diffusion-model/SKILL.md | 2 +- .claude/skills/vllm-omni-test/SKILL.md | 18 +- .../vllm-omni-test/references/test-routing.md | 31 +- docs/contributing/ci/CI_5levels.md | 17 +- .../test_examples/l4_performance_tests.inc.md | 122 +++- docs/contributing/ci/test_guide.md | 14 +- docs/contributing/ci/tests_markers.md | 31 +- recipes/LTX/LTX-2.3.md | 4 +- tests/dfx/conftest.py | 352 ++++++++- tests/dfx/perf/scripts/run_benchmark.py | 49 +- .../perf/scripts/run_diffusion_benchmark.py | 189 +++-- .../dfx/perf/tests/test_bagel_vllm_omni.json | 48 ++ .../perf/tests/test_cosmos3_vllm_omni.json | 458 ++++++------ tests/dfx/perf/tests/test_higgs_audio_v3.json | 12 + .../perf/tests/test_hunyuan_image3_it2i.json | 284 ++++---- .../tests/test_hunyuan_image_tp2_cfgp2.json | 78 +- .../tests/test_hunyuan_image_tp2_sp2.json | 78 +- .../perf/tests/test_hunyuan_image_tp4.json | 78 +- tests/dfx/perf/tests/test_ltx2_vllm_omni.json | 478 +++++++------ .../tests/test_qwen3_omni_async_chunk.json | 668 +++++++++--------- .../tests/test_qwen3_omni_multi_replicas.json | 236 ++++--- .../tests/test_qwen3_omni_no_async_chunk.json | 346 ++++----- .../perf/tests/test_qwen3_omni_vllm_text.json | 366 +++++----- .../test_qwen_image_edit_2511_vllm_omni.json | 324 +++++---- .../tests/test_qwen_image_edit_vllm_omni.json | 314 ++++---- .../test_qwen_image_layered_vllm_omni.json | 106 +-- .../perf/tests/test_qwen_image_vllm_omni.json | 484 +++++++------ tests/dfx/perf/tests/test_runner_metadata.py | 199 ++++++ tests/dfx/perf/tests/test_tts.json | 36 + tests/dfx/perf/tests/test_voxcpm2.json | 12 + .../perf/tests/test_wan22_i2v_vllm_omni.json | 232 +++--- 31 files changed, 3475 insertions(+), 2191 deletions(-) diff --git a/.claude/skills/add-diffusion-model/SKILL.md b/.claude/skills/add-diffusion-model/SKILL.md index 906f3c485..76c894473 100644 --- a/.claude/skills/add-diffusion-model/SKILL.md +++ b/.claude/skills/add-diffusion-model/SKILL.md @@ -334,7 +334,7 @@ Classify the model's **CI priority** first: | **L1** | `tests/diffusion/models/{slug}/`, `tests/diffusion/cache/`, transformer unit tests | `core_model` + `cpu` | `test-ready.yml` | Weight remap, `_sp_plan`, cache enabler registration, shape contracts | | **L2** | `tests/e2e/online_serving/test_{slug}.py` (and offline if the category is offline-first) | **`core_model` + `advanced_model`** (both on baseline smoke) + `diffusion` + `@hardware_test` / `hardware_marks` | `test-ready.yml` | Default deploy smoke — minimal `num_inference_steps`, single prompt | | **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` when offline matters | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `diffusion`) | `test-merge.yml` **or** merged into nightly diffusion function job | Real weights, streaming/API paths, LoRA/offload smoke | -| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` | +| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` with per-case **`mark`** (`hardware_marks` + `full_model` + `diffusion`) | **L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, minimal steps, single prompt) should carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** function so L2 (`test-ready.yml`) and L3 (`test-merge.yml`) share one smoke test. Heavier deploy variants or API paths in the same file use **`advanced_model` only**. When L3 moves to nightly, migrate those heavier cases into `test_{slug}_expansion.py` with `full_model` and remove the dedicated `test-merge.yml` job (see `test_longcat_image_expansion.py`, `test_qwen_image_expansion.py`). diff --git a/.claude/skills/vllm-omni-test/SKILL.md b/.claude/skills/vllm-omni-test/SKILL.md index 40a81f4cf..dcc2506da 100644 --- a/.claude/skills/vllm-omni-test/SKILL.md +++ b/.claude/skills/vllm-omni-test/SKILL.md @@ -97,7 +97,7 @@ Existing references: `tests/e2e/offline_inference/test_qwen2_5_omni.py` (L2-styl | **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `omni_runner_handler`. **Function (isolation only):** `omni_runner_function` + `omni_runner_handler_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required | | **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `openai_client`. **Function (isolation only):** `omni_server_function` + `openai_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` | | **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). | -| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: `full_model` + `benchmark`; wire `test-nightly.yml` Perf steps | +| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: JSON `mark` with `full_model` + `omni`/`tts`/`diffusion`; wire `test-nightly.yml` Perf steps | | **Invalid parameter / negative HTTP validation** | `tests/dfx/reliability/invalid_param_test/` | Live `omni_server` + low-level `send_*_http_request` with `err_code` / `err_message` | `pytest.mark.slow` + `omni` / `tts` / `diffusion` + `@hardware_marks` (`H100` or `L4`); CI in **`test-weekly.yml`** (not ready/merge/nightly) | **If the user’s test plan includes invalid parameter validation / invalid params / negative HTTP / 400 validation:** do **not** add those `test_*` functions to `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move or author them** under `tests/dfx/reliability/invalid_param_test/` in the **endpoint-matching script** (see **Invalid parameter validation** below). Success-path e2e and invalid-param dfx tests must stay in separate modules. @@ -270,7 +270,7 @@ def test_text_to_video_001(omni_server, openai_client) -> None: *Documentation example tests:* follow the **Preferred Test Strategy** in [l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md): dynamic extraction for offline READMEs; explicit copied client code for online pages until extraction is justified; use the documented **naming**, **output directory** (page folder + case id), and **skipping** rules (e.g. Gradio-only scripts). -*Performance tests:* add or extend entries under `tests/perf/` (and JSON configs where the project uses them), with **explicit baselines** and the same marker/run-level pairing as the CI step that will execute them. +*Performance tests:* add or extend entries under `tests/dfx/perf/tests/` (and JSON configs where the project uses them), with **explicit baselines**, **`mark`** on each case (`hardware_marks` + `full_model` + type marker), and the same nightly Perf step pattern as in-tree configs. **3. Cross-cutting rules** @@ -592,27 +592,31 @@ Implementation strategy: **When the user explicitly asks for L4 perf (or full L4 including perf):** 1. **Do not** put throughput/latency assertions inside `test_*_expansion.py` — perf uses the **dfx benchmark harness**, not `omni_server` e2e fixtures. -2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`). -3. Each JSON entry: `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]` with explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …). +2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json`, `test_qwen3_omni_async_chunk.json`). +3. Each JSON **case**: `test_name`, optional **`mark`** (`hardware_marks` required when present; `marks`: `full_model` + `omni`/`tts`/`diffusion`), `server_params` (`model`, `serve_args` or `stage_config_name`), `benchmark_params[]` with **`name`** and explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …). Diffusion cases also set `server_type` (e.g. `"vllm-omni"`) and usually `benchmark_endpoint`. 4. Run locally via the matching script (model type → runner): | Model type | Runner script | Example config | |------------|---------------|----------------| | **Diffusion X2I/X2V** | `tests/dfx/perf/scripts/run_diffusion_benchmark.py` | `tests/dfx/perf/tests/test_qwen_image_vllm_omni.json` | | **TTS** | `tests/dfx/perf/scripts/run_benchmark.py` | `tests/dfx/perf/tests/test_tts.json` (shared) or `test_{slug}.json` (dedicated; use when the model must not join the shared nightly matrix before integration — e.g. `test_voxcpm2.json`, `test_coqui_tts.json`) | -| **Omni** | (see existing Omni Perf steps — `run_benchmark.py` / dedicated omni configs in-tree) | `tests/dfx/perf/tests/test_omni*.json` | +| **Omni** | `tests/dfx/perf/scripts/run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` | -5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with `--test-config-file`, **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf. +5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with **`--test-config-file`** (nightly perf steps do not use `-m`), **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf. 6. **One benchmark scenario → one `test_name` block** in JSON (same spirit as one `test_*` per function case). Combine server `serve_args` + workload in one entry; use multiple `benchmark_params` rows for size/step sweeps under the same server config. -**Perf local command (diffusion example):** +**Perf local commands:** ```bash cd tests export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN +# Single file (CI-like) pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \ --test-config-file dfx/perf/tests/test_<model>_vllm_omni.json +# Bulk load + filter by JSON mark +pytest -sv dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" +pytest -sv dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" ``` **Clarify in the test plan** which L4 pillars you deliver: `Function only` | `Function + Perf` | `Function + Accuracy + Perf`. diff --git a/.claude/skills/vllm-omni-test/references/test-routing.md b/.claude/skills/vllm-omni-test/references/test-routing.md index 445a78399..f78e1c77d 100644 --- a/.claude/skills/vllm-omni-test/references/test-routing.md +++ b/.claude/skills/vllm-omni-test/references/test-routing.md @@ -127,8 +127,12 @@ pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and cd tests export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN +# CI-like: single JSON file (nightly Perf Test steps) pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \ --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json +# Local bulk load: all *.json under tests/dfx/perf/tests/, filter by JSON mark +pytest -sv dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" +pytest -sv dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" ``` Broad marker sweep (when no explicit file shard): @@ -150,7 +154,26 @@ Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not **Rule:** “L4 functional cases” → **Function only** by default. State that Perf/Accuracy are separate pillars; add them only when requested. -**Diffusion perf JSON** (`tests/dfx/perf/tests/test_<slug>_vllm_omni.json`): array of objects with `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]`, and per-workload **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`). Copy structure from `test_qwen_image_vllm_omni.json` in-tree. +**Diffusion perf JSON** (`tests/dfx/perf/tests/test_<slug>_vllm_omni.json`): array of **case objects**, each with: + +| Field | Required | Notes | +|-------|----------|-------| +| `test_name` | Yes | Unique server/workload id | +| `mark` | Recommended | When present: **`hardware_marks` required** + optional `marks` (`full_model`, `diffusion`, `local_model`, …). Parsed by `resolve_pytest_marks`; enables local `-m` filtering. | +| `server_type` | Diffusion | e.g. `"vllm-omni"` — routes case to `run_diffusion_benchmark.py` | +| `benchmark_endpoint` | Diffusion | e.g. `/v1/videos`, `/v1/images/generations` | +| `server_params` | Yes | `model`, `serve_args`, … | +| `benchmark_params[]` | Yes | Each row: **`name`** (pytest id suffix), workload fields, **`baseline`** | + +Copy structure from `test_qwen_image_vllm_omni.json` or `test_cosmos3_vllm_omni.json` (2-GPU mark example). + +**Omni perf JSON**: `test_qwen3_omni_*.json` — same `mark` shape with `"marks": ["full_model", "omni"]`; run via `run_benchmark.py`. + +**Runner split**: `is_diffusion_perf_config()` → diffusion when `server_type` is set or `"diffusion"` ∈ `mark.marks`; otherwise omni/tts → `run_benchmark.py`. + +**Param ids**: `{test_name}-{benchmark_params.name}` (e.g. `test_tts-p0`, not `test_tts-0`). + +**Result filenames**: runtime hardware from `get_runtime_resource_label()`; `H100` omitted on default CI pool. **Diffusion perf nightly step** (under **Diffusion X2I(&A&T)**; copy full `kubernetes` plugins from `· Perf Test · Qwen-Image`): @@ -177,9 +200,9 @@ Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not | Model type | Perf runner | Config example | |------------|-------------|----------------| -| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json` | -| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json` (`BENCHMARK_DIR`) | -| Omni | `run_benchmark.py` / omni configs in-tree | `test_omni*.json` | +| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json` | +| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json` | +| Omni | `dfx/perf/scripts/run_benchmark.py` | `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_no_async_chunk.json`, … | Do **not** put throughput/latency baselines inside `test_*_expansion.py` — that belongs in the dfx perf JSON + nightly Perf job. diff --git a/docs/contributing/ci/CI_5levels.md b/docs/contributing/ci/CI_5levels.md index 17b4a3792..c9d86e1c4 100644 --- a/docs/contributing/ci/CI_5levels.md +++ b/docs/contributing/ci/CI_5levels.md @@ -126,11 +126,12 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari <strong>Interface tests:</strong><br> /tests/entrypoints/test_xxx<br> <strong>Performance:</strong><br> - /tests/dfx/perf/tests/test_qwen_omni.json (Omni), test_tts.json (TTS),<br> - and /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)<br> + /tests/dfx/perf/tests/test_qwen3_omni_*.json (Omni), test_tts.json (TTS),<br> + test_voxcpm2.json, test_higgs_audio_v3.json, and<br> + /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)<br> <strong>Doc Test:</strong><br> - tests/example/online_serving/test_{model_name}.py<br> - tests/example/offline_inference/test_{model_name}.py<br> + tests/examples/online_serving/test_{model_name}.py<br> + tests/examples/offline_inference/test_{model_name}.py<br> <strong>Accuracy Test:</strong><br> /tests/e2e/accuracy/test_{model_name}.py </td> @@ -708,16 +709,16 @@ L4 level testing is a comprehensive quality audit before a version release. It e ### 3.2 Testing Content and Scope - ***Full Functionality Testing***: Executes all test cases defined in `test_{model_name}_expansion.py`, covering all implemented features, positive flows, boundary conditions, and exception handling. -- ***Performance Testing***: Uses `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs in the form `tests/dfx/perf/tests/test_*_vllm_omni.json` (passed to `run_benchmark.py` via `--test-config-file`) to drive performance testing tools for stress, load, and endurance tests, collecting metrics like throughput, response time, and resource utilization. +- ***Performance Testing***: Uses `tests/dfx/perf/tests/test_qwen3_omni_*.json` (Omni), `test_tts.json` / `test_voxcpm2.json` / `test_higgs_audio_v3.json` (TTS), and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (passed to `run_benchmark.py` or `run_diffusion_benchmark.py` via `--test-config-file` in nightly **Perf Test** steps) to drive throughput, latency, and memory benchmarks. Each JSON **case** may declare an optional top-level **`mark`** array: exactly one ``hardware_marks`` object plus pytest marker name strings (`full_model`, `omni` / `tts` / `diffusion`, …). Runners attach those marks to each parametrized `(server, benchmark index)` pair so **local** bulk runs can filter with `-m` (for example `-m "full_model and H100 and diffusion"`). Nightly CI perf jobs select workloads by **`--test-config-file`**, not `-m`. Details are in the Performance Tests example below. - ***Documentation Testing***: Verifies whether the example code provided to users is runnable and its results match the description. ### 3.3 Test Directory and Execution Files - ***Functional Testing***: Same directories as L3. -- ***Performance Test Configuration***: `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (e.g. `test_qwen_image_vllm_omni.json`) +- ***Performance Test Configuration***: `tests/dfx/perf/tests/test_qwen3_omni_*.json`, `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json`, and diffusion configs `tests/dfx/perf/tests/test_*_vllm_omni.json` (e.g. `test_qwen_image_vllm_omni.json`, `test_cosmos3_vllm_omni.json`). Optional per-case `mark` for local `-m` filtering is documented in Section 3.4 Performance Tests. - ***Documentation Example Tests***: -- - `tests/example/online_serving/test_{model_name}.py` - - `tests/example/offline_inference/test_{model_name}.py` + - `tests/examples/online_serving/test_{model_name}.py` + - `tests/examples/offline_inference/test_{model_name}.py` ### 3.4 Execution Method and Example diff --git a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md index f1f3073dc..5c59ba499 100644 --- a/docs/contributing/ci/test_examples/l4_performance_tests.inc.md +++ b/docs/contributing/ci/test_examples/l4_performance_tests.inc.md @@ -1,14 +1,41 @@ -When you want to add L4-level ***performance test*** cases, you can refer to the following format for case addition in `tests/dfx/perf/tests/test_qwen_omni.json`, `tests/dfx/perf/tests/test_tts.json`, or diffusion configs such as `tests/dfx/perf/tests/test_*_vllm_omni.json` (selected via `pytest ... run_benchmark.py --test-config-file <path>`): +When you want to add L4-level ***performance test*** cases, add entries to JSON files under `tests/dfx/perf/tests/` and run them via `tests/dfx/perf/scripts/run_benchmark.py` (omni / TTS) or `run_diffusion_benchmark.py` (diffusion). + +**Config file layout (in-tree examples)** + +| Model type | Runner | Example JSON files | +| ---------- | ------ | ------------------ | +| Omni | `run_benchmark.py` | `test_qwen3_omni_no_async_chunk.json`, `test_qwen3_omni_async_chunk.json`, `test_qwen3_omni_vllm_text.json`, `test_qwen3_omni_multi_replicas.json` | +| TTS | `run_benchmark.py` | `test_tts.json`, `test_voxcpm2.json`, `test_higgs_audio_v3.json` | +| Diffusion | `run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`, `test_wan22_i2v_vllm_omni.json`, `test_cosmos3_vllm_omni.json`, … | + +**How runners pick cases** + +Without **`--test-config-file`**, each runner scans all `*.json` under `tests/dfx/perf/tests/` but only keeps its own model type: + +- **`run_benchmark.py`**: omni and TTS cases only (skips diffusion JSON). +- **`run_diffusion_benchmark.py`**: diffusion cases only (skips omni / TTS JSON). + +Diffusion cases are detected when the JSON has `server_type` (typically `"vllm-omni"`) or `"diffusion"` in the `mark` array. Omni / TTS JSON has neither. + +**Running perf cases** + +Pass **`--test-config-file`** to run one JSON file as-is, or omit it for the bulk scan above and filter with pytest **`-m`** on each case's JSON `mark` (for example `pytest … run_benchmark.py -m "full_model and tts and H100"`). ```JSON { - "test_name": "test_qwen3_omni", + "test_name": "test_qwen3_omni_async_chunk", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "omni" + ], "server_params": { "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", "stage_config_name": "qwen3_omni.yaml" }, "benchmark_params": [ { + "name": "random_10p", "dataset_name": "random", "num_prompts": [10, 20], "max_concurrency": [1, 4], @@ -33,8 +60,95 @@ When you want to add L4-level ***performance test*** cases, you can refer to the | Field | Required | Description | | ---------------- | -------- | --------------------------------------------------------------- | | test_name | Yes | Unique identifier for the test case | +| mark | No | Pytest marks for this case (see **`mark` field** below). Omit only for configs not meant to be filtered by `-m`. | | server_params | Yes | Server-side configuration parameters | | benchmark_params | Yes | Benchmark running parameters (supports multiple configurations) | +| server_type | Diffusion only | When set (e.g. `"vllm-omni"`), routes the case to `run_diffusion_benchmark.py`. | +| benchmark_endpoint | Diffusion only | API path for the benchmark client (e.g. `/v1/videos`, `/v1/images/generations`). | + +**`mark` field** + +Optional top-level field on each perf JSON **case object** (one per `test_name`). `run_benchmark.py` and `run_diffusion_benchmark.py` read it via `tests.dfx.conftest.resolve_pytest_marks` and attach the marks to the corresponding `pytest.param`, so you can filter locally with `-m`. + +When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same shape as `@hardware_test` / `hardware_marks()` in `tests/helpers/mark.py`), followed by registered pytest marker name strings such as `full_model`, `omni`, `tts`, `diffusion`, or `local_model`. + +Supported form: + +| Form | Example | Effect | +| ---- | ------- | ------ | +| Array (single platform) | `"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "diffusion"]` | Hardware marks from `hardware_marks(...)`; `num_cards` > 1 adds `distributed_*` (+ CUDA `skipif_cuda` when applicable). String entries become extra pytest marks. | +| Array (multi-platform) | `"mark": [{"hardware_marks": {"res": {"cuda": "H100", "rocm": "MI325", "npu": "A2"}, "num_cards": {"cuda": 2, "rocm": 2, "npu": 1}}}, "full_model", "omni"]` | Same as above, but declares **multiple platforms** in one case. Filter examples: `-m "full_model and H100 and cuda"`, `-m "full_model and MI325 and rocm"`. | + +Recommended for L4 perf cases: + +```JSON +{ + "test_name": "test_bagel_single_device_single_stage_t2i", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "server_params": { "...": "..." }, + "benchmark_params": [ { "name": "1024x1024_steps20", "...": "..." } ] +} +``` + +Multi-GPU diffusion (example: Cosmos3 with `cfg-parallel-size=2`): + +```JSON +{ + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/generations", + "server_params": { "...": "..." }, + "benchmark_params": [ { "name": "1024x1024_steps4", "...": "..." } ] +} +``` + +- Omni perf: include `"omni"` in the `mark` array +- TTS perf: include `"tts"` in the `mark` array +- Diffusion perf: include `"diffusion"` in the `mark` array +- HunyuanImage local-weight cases: add `"local_model"` to the `mark` array + +**Parametrization IDs** + +Each `(server, benchmark index)` pair becomes one `pytest.param` with id `{test_name}-{suffix}`. The suffix comes from `benchmark_params[].name` when set (for example `test_tts-p0`, `test_omni-p1`); otherwise it is derived from fields like `task` / `eval_phase`. + +**Benchmark result filenames** + +Result files use the **runtime** hardware label from `get_runtime_resource_label()` (detected GPU/NPU name on the machine that ran the job), **not** `mark.hardware_marks.res`. On the default H100 CI pool, `H100` is **omitted** from filenames (`resource_label_for_filename`). + +Examples: + +- Omni/TTS: `result_{test_name}_{optional_hw}_{dataset}_....json` under `BENCHMARK_DIR` +- Diffusion: one aggregate `diffusion_result_{config_stem}_{optional_hw}_{timestamp}.json` per source JSON file (array of all runs from that file) + +**Local commands** + +```bash +# Bulk load + filter by JSON mark +pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and H100 and diffusion" +pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m "full_model and omni and H100" + +# Single file (same as nightly CI Perf steps) +pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py \ + --test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json +pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \ + --test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json + +# Optional baseline assertion (default off) +pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py --assert-baseline \ + --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json +``` + +See also [Markers for Tests](./tests_markers.md) for registered hardware markers (`H100`, `L4`, `cuda`, `distributed_cuda`, …). **`server_params` Configuration** @@ -79,10 +193,12 @@ You can add any benchmark running parameters you need here. For all optional par 1. Change the --xxx-xx-xx running parameters to xxx_xx_xx format and fill them as keys in the JSON file. 2. For boolean variables in the running parameters, modify them to forms such as ignore_eos: true/false and fill them into the JSON file. 3. Optionally add a `baseline` object (see **Baseline thresholds** below). If you omit `baseline` or leave it empty, the performance test still runs but does not assert metric thresholds from this field. -4. The qps and concurrency modes are recommended to be mutually exclusive. For detailed explanations, see the table below: +4. Set `"name"` on each `benchmark_params` entry for stable pytest ids and readable result keys. +5. The qps and concurrency modes are recommended to be mutually exclusive. For detailed explanations, see the table below: | Parameter | Type | Required | Example/Values | Description | | --------------- | ----------- | -------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | string | No | `"1024x1024_steps4"` | Stable suffix for pytest param id and logs. | | num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. | | request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. | | max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). | diff --git a/docs/contributing/ci/test_guide.md b/docs/contributing/ci/test_guide.md index 26996129e..ad6a63963 100644 --- a/docs/contributing/ci/test_guide.md +++ b/docs/contributing/ci/test_guide.md @@ -149,12 +149,18 @@ Failed jobs: 1/2 pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model pytest -s -v -m "full_model and (omni or tts) and H100" --run-level=full_model ``` - - To run a standalone perf benchmark (defaults to ``test_qwen_omni.json``; use ``--test-config-file tests/dfx/perf/tests/test_tts.json`` for TTS): - + If you only want to run specific test cases on a particular platform, you can use: ```bash - pytest -s -v tests/dfx/perf/scripts/run_benchmark.py + pytest -s -v -m "full_model and distributed_cuda and L4" --run-level=full_model ``` + Note: ``run_benchmark.py`` and ``run_diffusion_benchmark.py`` accept an optional ``--test-config-file``. If omitted, each loads every ``*.json`` under ``tests/dfx/perf/tests/`` (omni/tts vs diffusion split by ``is_diffusion_perf_config``) and pytest ``-m`` filters by each case's JSON ``mark``: + ```bash + pytest -sv tests/dfx/perf/scripts/run_benchmark.py -m "full_model and tts and H100" + pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100" + pytest -sv tests/dfx/perf/scripts/run_benchmark.py --test-config-file tests/dfx/perf/tests/test_tts.json + pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_cosmos3_vllm_omni.json + ``` + Nightly **Perf Test** jobs in ``test-nightly.yml`` use ``--test-config-file`` only (no ``-m``). E2e L4 function tests still use ``full_model`` + ``--run-level full_model`` (see [test-nightly.yml](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/test-nightly.yml)). Example: === "L5 level" diff --git a/docs/contributing/ci/tests_markers.md b/docs/contributing/ci/tests_markers.md index cc89cee47..0a0a974e6 100644 --- a/docs/contributing/ci/tests_markers.md +++ b/docs/contributing/ci/tests_markers.md @@ -33,7 +33,8 @@ Defined in `pyproject.toml`: | `skipif_rocm` | Skip if the num of ROCm cards is less than the required * | | `skipif_npu` | Skip if the num of NPU cards is less than the required * | | `slow` | Slow tests (may skip in quick CI) | -| `benchmark` | Benchmark tests | +| `benchmark` | Benchmark tests (decorator on runner test functions; perf JSON uses `full_model` + type marker instead) | +| `local_model` | Tests requiring local / non-HF-hub model weights | \* Means those markers are auto-added by `@hardware_test` (parametrization decorator) or `hardware_marks` (only returning the list of marks for flexibility). @@ -94,7 +95,7 @@ This decorator is intended to make hardware-aware, cross-platform test authoring num_cards=2, ) ``` -- `res` must be a dict; supported resources: CUDA (L4/H100), ROCm (MI325), NPU (A2/A3) +- `res` must be a dict; supported resources: CUDA (L4/H100), ROCm (MI325), XPU (B60), MUSA (S5000), NPU (A2/A3) - `num_cards` can be int (all platforms) or dict (per platform); defaults to 1 when missing - Distributed markers (`distributed_cuda`, `distributed_rocm`, `distributed_npu`) are auto-added for multi-card cases - Filtering examples: @@ -120,6 +121,32 @@ def test_feature(omni_server): ... ``` +### JSON `mark` field (L4 perf configs) + +Perf JSON under `tests/dfx/perf/tests/` can attach pytest marks per **case** (`test_name` block). Parsed by `tests.dfx.conftest.resolve_pytest_marks` and applied to each parametrized run in `run_benchmark.py` / `run_diffusion_benchmark.py`. + +When `mark` is present, it must be an **array** with exactly one ``hardware_marks`` object (same semantics as `hardware_marks()` above), followed by registered pytest marker name strings such as `full_model`, `omni`, `tts`, or `diffusion`. + +```json +{ + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ], + "server_type": "vllm-omni", + "server_params": { "...": "..." }, + "benchmark_params": [{ "name": "1024x1024_steps4", "...": "..." }] +} +``` + +- Local bulk load: `pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py -m "full_model and diffusion and H100"` +- Nightly CI perf steps: `--test-config-file tests/dfx/perf/tests/test_<model>_vllm_omni.json` (file selects cases; no `-m`) +- Result filenames use **runtime** GPU detection (`get_runtime_resource_label`); `H100` is omitted on the default CI pool + +See [L4 performance test examples](./test_examples/l4_performance_tests.inc.md) for the full schema. + ## Add Support for a New Platform If you want to add support for a new platform (e.g., "tpu" for a new accelerator), follow these steps: diff --git a/recipes/LTX/LTX-2.3.md b/recipes/LTX/LTX-2.3.md index c83a14883..7a4f1507c 100644 --- a/recipes/LTX/LTX-2.3.md +++ b/recipes/LTX/LTX-2.3.md @@ -148,7 +148,7 @@ Do not copy latency or VRAM numbers between commits or machines. Record them only after running a latest-head sweep on the target hardware. For formal PR benchmarking, reuse -`tests/dfx/perf/tests/test_ltx2_3_vllm_omni.json` with -`tests/dfx/perf/scripts/run_diffusion_benchmark.py`. That config captures the +`tests/dfx/perf/tests/test_ltx2_vllm_omni.json` with +`tests/dfx/perf/scripts/run_diffusion_benchmark.py`. Each case in that file includes a JSON **`mark`** (`hardware_marks` + `full_model` + `diffusion`) for local `-m` filtering. That config captures the single-device eager baseline and CFG-parallel=2 case for a small 384x512, 25-frame, 20-step workload. diff --git a/tests/dfx/conftest.py b/tests/dfx/conftest.py index a22f760b2..2eb88bbd6 100644 --- a/tests/dfx/conftest.py +++ b/tests/dfx/conftest.py @@ -11,11 +11,180 @@ from typing import Any import pytest from tests.dfx.reliability.helpers import list_remote_process_pids_by_pattern, post_chat_completions_raw +from tests.helpers.mark import hardware_marks from tests.helpers.runtime import OmniServerParams from tests.helpers.stage_config import modify_stage_config from vllm_omni.platforms import current_omni_platform +def _named_pytest_marks(names: list[str]) -> list[pytest.MarkDecorator]: + marks: list[pytest.MarkDecorator] = [] + for name in names: + name = name.strip() + if not name: + raise ValueError("mark name must be a non-empty string") + marks.append(getattr(pytest.mark, name)) + return marks + + +def _hardware_marks_from_dict(hw: Any) -> list[pytest.MarkDecorator]: + if not isinstance(hw, dict): + raise ValueError(f"mark.hardware_marks must be a dict, got {type(hw).__name__}") + res = hw.get("res") + if not isinstance(res, dict): + raise ValueError(f"mark.hardware_marks.res must be a dict, got {type(res).__name__}") + num_cards = hw.get("num_cards", 1) + return list(hardware_marks(res=res, num_cards=num_cards)) + + +def resolve_pytest_marks(mark_field: Any) -> list[pytest.MarkDecorator]: + """Convert a JSON ``mark`` field into pytest mark decorators. + + Supported form (per test-case object in perf/stability JSON):: + + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion" + ] + + Exactly one ``hardware_marks`` object is required when ``mark`` is present. + ``hardware_marks`` delegates to :func:`tests.helpers.mark.hardware_marks` + (same shape as ``@hardware_test``). Additional array entries are registered + pytest marker names (strings). + """ + if mark_field is None: + return [] + + if isinstance(mark_field, list): + marks: list[pytest.MarkDecorator] = [] + hw_seen = False + for item in mark_field: + if isinstance(item, dict) and "hardware_marks" in item: + if hw_seen: + raise ValueError("mark array must contain at most one hardware_marks object") + hw_seen = True + marks.extend(_hardware_marks_from_dict(item["hardware_marks"])) + unknown_keys = set(item) - {"hardware_marks"} + if unknown_keys: + raise ValueError( + f"mark hardware_marks object only allows hardware_marks; unknown keys: {sorted(unknown_keys)}" + ) + elif isinstance(item, str): + item = item.strip() + if not item: + raise ValueError("mark name must be a non-empty string") + marks.extend(_named_pytest_marks([item])) + else: + raise ValueError( + f"mark array entries must be hardware_marks objects or marker name strings; got {type(item).__name__}" + ) + if not hw_seen: + raise ValueError("mark array must contain a hardware_marks object") + return marks + + raise ValueError(f"mark must be a list; got {type(mark_field).__name__}") + + +def _mark_names(mark_field: Any) -> set[str]: + if isinstance(mark_field, list): + return {str(item) for item in mark_field if isinstance(item, str)} + return set() + + +def is_diffusion_perf_config(cfg: dict[str, Any]) -> bool: + """True for perf JSON cases intended for ``run_diffusion_benchmark.py``.""" + if cfg.get("server_type") is not None: + return True + return "diffusion" in _mark_names(cfg.get("mark")) + + +def _marks_by_test_name(configs: list[dict[str, Any]]) -> dict[str, list[pytest.MarkDecorator]]: + return {str(cfg["test_name"]): resolve_pytest_marks(cfg.get("mark")) for cfg in configs} + + +def create_unique_server_pytest_params( + configs: list[dict[str, Any]], + stage_configs_dir: Path, +) -> list[Any]: + """Like :func:`create_unique_server_params`, but wrap each row in ``pytest.param`` with JSON marks.""" + marks_by_name = _marks_by_test_name(configs) + return [ + pytest.param( + row, + marks=marks_by_name.get(row[0], []), + id=row[0], + ) + for row in create_unique_server_params(configs, stage_configs_dir) + ] + + +def create_benchmark_pytest_params( + benchmark_configs: list[dict[str, Any]], + server_to_benchmark_mapping: dict[str, dict], +) -> list[Any]: + """Like :func:`create_benchmark_indices`, but wrap each index in ``pytest.param`` with JSON marks.""" + marks_by_name = _marks_by_test_name(benchmark_configs) + params: list[Any] = [] + seen: set[str] = set() + for config in benchmark_configs: + test_name = config["test_name"] + if test_name in seen: + continue + seen.add(test_name) + params_list = get_benchmark_params_for_server(test_name, server_to_benchmark_mapping) + id_suffixes = _unique_benchmark_param_id_suffixes(params_list) + for idx, id_suffix in enumerate(id_suffixes): + params.append( + pytest.param( + (test_name, idx), + marks=marks_by_name.get(test_name, []), + id=f"{test_name}-{id_suffix}", + ) + ) + return params + + +def create_paired_benchmark_pytest_params( + server_entries: list[tuple[Any, str]], + params_by_test_name: dict[str, list[Any]], + marks_by_name: dict[str, list[pytest.MarkDecorator]], +) -> list[Any]: + """One ``pytest.param`` per ``(server entry, benchmark index)`` pair. + + Pass ``server_entry`` and ``(test_name, idx)`` as separate ``pytest.param`` + arguments for ``@pytest.mark.parametrize("server_fixture,benchmark_params", ...)``. + """ + pairs: list[Any] = [] + for server_entry, test_name in server_entries: + params_list = params_by_test_name.get(test_name, []) + id_suffixes = _unique_benchmark_param_id_suffixes(params_list) + for idx, id_suffix in enumerate(id_suffixes): + pairs.append( + pytest.param( + server_entry, + (test_name, idx), + marks=marks_by_name.get(test_name, []), + id=f"{test_name}-{id_suffix}", + ) + ) + return pairs + + +def create_paired_omni_benchmark_pytest_params( + configs: list[dict[str, Any]], + stage_configs_dir: Path, +) -> list[Any]: + """Paired params for ``run_benchmark.py`` (omni/tts).""" + mapping = create_test_parameter_mapping(configs) + marks_by_name = _marks_by_test_name(configs) + server_entries = [(row, row[0]) for row in create_unique_server_params(configs, stage_configs_dir)] + params_by_test_name = { + test_name: get_benchmark_params_for_server(test_name, mapping) for _, test_name in server_entries + } + return create_paired_benchmark_pytest_params(server_entries, params_by_test_name, marks_by_name) + + def load_configs(config_path: str) -> list[dict[str, Any]]: try: abs_path = Path(config_path).resolve() @@ -32,6 +201,29 @@ def load_configs(config_path: str) -> list[dict[str, Any]]: raise RuntimeError(f"Failed to load configuration file: {str(e)}") +def load_benchmark_configs( + config_path: str | None = None, + *, + config_dir: Path | None = None, +) -> list[dict[str, Any]]: + """Load one benchmark JSON file, or merge all ``*.json`` under *config_dir*. + + When *config_path* is omitted, every ``*.json`` in *config_dir* is loaded and + concatenated. Pytest ``-m`` expressions then select cases via each entry's + ``mark`` field (e.g. ``-m tts`` after bulk load). + """ + if config_path is not None: + return load_configs(config_path) + if config_dir is None: + raise ValueError("load_benchmark_configs requires config_path or config_dir") + configs: list[dict[str, Any]] = [] + for path in sorted(config_dir.glob("*.json")): + configs.extend(load_configs(str(path))) + if not configs: + raise ValueError(f"No benchmark JSON files found under {config_dir}") + return configs + + def modify_stage(default_path: str, updates: dict[str, Any] | None, deletes: dict[str, Any] | None) -> str: kwargs: dict[str, Any] = {} if updates is not None: @@ -293,6 +485,158 @@ def _safe_filename_token(value: Any | None, *, default: str = "na") -> str: return s if s else default +def _benchmark_param_id_suffix(param: dict[str, Any], *, idx: int) -> str: + """Derive a readable pytest id suffix from one ``benchmark_params`` entry.""" + name = param.get("name") + if isinstance(name, str) and name.strip(): + return _safe_filename_token(name.strip()) + + parts: list[str] = [] + for key in ("task", "eval_phase", "dataset_name", "dataset"): + value = param.get(key) + if value is None or value == "": + continue + token = _safe_filename_token(str(value)) + if token != "na": + parts.append(token) + if parts: + return "_".join(parts) + + return f"case{idx}" + + +def _unique_benchmark_param_id_suffixes(params_list: list[dict[str, Any]]) -> list[str]: + """Return unique pytest id suffixes for a server's benchmark param list.""" + raw = [_benchmark_param_id_suffix(param, idx=idx) for idx, param in enumerate(params_list)] + seen: dict[str, int] = {} + unique: list[str] = [] + for suffix in raw: + count = seen.get(suffix, 0) + seen[suffix] = count + 1 + if count == 0: + unique.append(suffix) + else: + unique.append(f"{suffix}_{count}") + return unique + + +def extract_mark_resource_label(mark_field: Any) -> str: + """Return a filename-safe hardware label from ``mark.hardware_marks.res`` values. + + Example: ``{"cuda": "H100"}`` -> ``"H100"``; multiple platforms join with ``-``. + + Prefer :func:`get_runtime_resource_label` for perf result filenames so labels + reflect the machine that actually ran the benchmark. + """ + if isinstance(mark_field, list): + for item in mark_field: + if isinstance(item, dict) and "hardware_marks" in item: + return extract_mark_resource_label(item) + return "na" + if not isinstance(mark_field, dict): + return "na" + hw = mark_field.get("hardware_marks") + if not isinstance(hw, dict): + return "na" + res = hw.get("res") + if not isinstance(res, dict) or not res: + return "na" + labels = [_safe_filename_token(value) for value in res.values()] + return "-".join(labels) if labels else "na" + + +_KNOWN_RUNTIME_RESOURCE_TOKENS: tuple[str, ...] = ( + "H100", + "H800", + "H200", + "H20", + "L40S", + "L40", + "L4", + "A100", + "A800", + "A10G", + "A10", + "A30", + "MI325", + "MI300", + "MI250", + "B60", + "S5000", + "910B4", + "910B", + "910", + "310P", + "A2", + "A3", +) +_RUNTIME_RESOURCE_LABEL: str | None = None + + +def _normalize_runtime_device_label(raw: str) -> str: + """Map a platform device name to a short filename-safe resource token.""" + if not raw or not str(raw).strip(): + return "na" + upper = str(raw).upper() + for token in _KNOWN_RUNTIME_RESOURCE_TOKENS: + if token.upper() in upper: + return _safe_filename_token(token) + compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw)) + for prefix in ("NVIDIA", "AMD", "ASCEND", "HUAWEI"): + if compact.upper().startswith(prefix): + compact = compact[len(prefix) :] + break + return _safe_filename_token(compact[:48]) if compact else "na" + + +def _read_runtime_device_name(*, device_id: int = 0) -> str | None: + """Device name from the active Omni platform.""" + if current_omni_platform.device_count() <= device_id: + return None + get_name = getattr(current_omni_platform, "get_device_name", None) + if not callable(get_name): + return None + raw = get_name(device_id) + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return None + + +def get_runtime_resource_label(*, device_id: int = 0, refresh: bool = False) -> str: + """Return a filename-safe hardware label detected on the running machine.""" + global _RUNTIME_RESOURCE_LABEL + if not refresh and _RUNTIME_RESOURCE_LABEL is not None: + return _RUNTIME_RESOURCE_LABEL + raw = _read_runtime_device_name(device_id=device_id) + label = _normalize_runtime_device_label(raw) if raw else "na" + if not refresh: + _RUNTIME_RESOURCE_LABEL = label + return label + + +_FILENAME_OMIT_RESOURCE_LABELS = frozenset({"H100"}) + + +def hardware_json_value(resource_label: str | None) -> str: + """Hardware token stored in perf result JSON (empty when unknown).""" + token = _safe_filename_token(resource_label) + return "" if token == "na" else token + + +def resource_label_for_filename(resource_label: str | None) -> str: + """Hardware token embedded in result filenames (H100 omitted on default CI pool).""" + token = _safe_filename_token(resource_label) + if token in _FILENAME_OMIT_RESOURCE_LABELS: + return "" + return token + + +def extract_configs_resource_label(configs: list[dict[str, Any]]) -> str: + """Return runtime hardware label for perf result filenames.""" + del configs + return get_runtime_resource_label() + + def resolve_baseline_value( baseline_raw: Any, *, @@ -357,6 +701,7 @@ def run_benchmark( max_concurrency: Any | None = None, random_input_len: Any | None = None, random_output_len: Any | None = None, + resource_label: str | None = None, ) -> dict[str, Any]: """Run one ``vllm bench serve --omni`` iteration and return parsed metrics. @@ -367,7 +712,11 @@ def run_benchmark( current_dt = datetime.now().strftime("%Y%m%d-%H%M%S") ri = _safe_filename_token(random_input_len) ro = _safe_filename_token(random_output_len) - result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" + hw = resource_label_for_filename(resource_label) + if hw: + result_filename = f"result_{test_name}_{hw}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" + else: + result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json" if "--result-filename" in args: print(f"The result file will be overwritten by {result_filename}") command = ( @@ -437,6 +786,7 @@ def run_benchmark( result["random_input_len"] = random_input_len if random_output_len is not None: result["random_output_len"] = random_output_len + result["Hardware"] = hardware_json_value(resource_label) with open(result_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) return result diff --git a/tests/dfx/perf/scripts/run_benchmark.py b/tests/dfx/perf/scripts/run_benchmark.py index 13329a209..21167cb07 100644 --- a/tests/dfx/perf/scripts/run_benchmark.py +++ b/tests/dfx/perf/scripts/run_benchmark.py @@ -7,21 +7,24 @@ from typing import Any import pytest from tests.dfx.conftest import ( - create_benchmark_indices, + create_paired_omni_benchmark_pytest_params, create_test_parameter_mapping, - create_unique_server_params, get_benchmark_params_for_server, - load_configs, + get_runtime_resource_label, + is_diffusion_perf_config, + load_benchmark_configs, resolve_baseline_value, run_benchmark, ) from tests.helpers.runtime import OmniServer -pytestmark = [pytest.mark.full_model] - # Compare metrics to each test JSON ``baseline`` block only when pytest is run with ``--assert-baseline`` # (registered in ``tests/dfx/conftest.py``; default: off). ``run_benchmark`` and ``_resolve_baseline_value`` are # defined in the same module. +# +# Optional JSON field ``mark`` is applied as pytest marks via +# ``create_paired_omni_benchmark_pytest_params`` (e.g. ``"mark": [{"hardware_marks": +# {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "omni"]``). os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" @@ -40,22 +43,22 @@ def _get_config_file_from_argv() -> str | None: _PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" -_DEFAULT_CONFIG_FILE = str(_PERF_TESTS_DIR / "test_qwen_omni.json") CONFIG_FILE_PATH = _get_config_file_from_argv() if CONFIG_FILE_PATH is None: + _all_configs = load_benchmark_configs(config_dir=_PERF_TESTS_DIR) + BENCHMARK_CONFIGS = [cfg for cfg in _all_configs if not is_diffusion_perf_config(cfg)] print( - "No --test-config-file in argv, using default: tests/dfx/perf/tests/test_qwen_omni.json " - "(override with e.g. --test-config-file tests/dfx/perf/tests/test_tts.json)" + f"No --test-config-file: loaded {len(BENCHMARK_CONFIGS)} omni/tts case(s) from " + f"{_PERF_TESTS_DIR}/*.json (skipped {len(_all_configs) - len(BENCHMARK_CONFIGS)} diffusion; " + f"use -m to filter, e.g. -m tts)" ) - CONFIG_FILE_PATH = _DEFAULT_CONFIG_FILE - -BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) - +else: + BENCHMARK_CONFIGS = load_benchmark_configs(CONFIG_FILE_PATH) DEPLOY_CONFIGS_DIR = Path(__file__).parent.parent / "deploy" -test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) +paired_benchmark_params = create_paired_omni_benchmark_pytest_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) _omni_server_lock = threading.Lock() @@ -91,17 +94,11 @@ def omni_server(request): print("OmniServer stopped") -benchmark_indices = create_benchmark_indices(BENCHMARK_CONFIGS, server_to_benchmark_mapping) - - @pytest.fixture -def benchmark_params(request, omni_server): - """Benchmark parameters fixture with proper parametrization""" +def benchmark_params(request): + """Benchmark parameters fixture; paired with ``omni_server`` via parametrization.""" test_name, param_index = request.param - if test_name != omni_server.test_name: - pytest.skip(f"Skipping parameter for {test_name} - current server is {omni_server.test_name}") - all_params = get_benchmark_params_for_server(test_name, server_to_benchmark_mapping) if not all_params: @@ -153,8 +150,11 @@ def assert_result( @pytest.mark.benchmark -@pytest.mark.parametrize("omni_server", test_params, indirect=True) -@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True) +@pytest.mark.parametrize( + "omni_server,benchmark_params", + paired_benchmark_params, + indirect=["omni_server", "benchmark_params"], +) def test_performance_benchmark(omni_server, benchmark_params, request): test_name = benchmark_params["test_name"] params = benchmark_params["params"] @@ -168,6 +168,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request): print(f"Benchmark parameters: {benchmark_params}") assert_baseline = request.config.getoption("--assert-baseline", default=False) + resource_label = get_runtime_resource_label() def to_list(value, default=None): if value is None: @@ -239,6 +240,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request): max_concurrency=None, random_input_len=params.get("random_input_len"), random_output_len=params.get("random_output_len"), + resource_label=resource_label, ) assert_result( result, @@ -264,6 +266,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request): max_concurrency=concurrency, random_input_len=params.get("random_input_len"), random_output_len=params.get("random_output_len"), + resource_label=resource_label, ) assert_result( result, diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py index b0860978b..d19c0900e 100644 --- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py +++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py @@ -8,15 +8,21 @@ This runner separates two concepts: 2. ``benchmark_endpoint``: which serving API the benchmark client calls. Examples: ``/v1/chat/completions`` and ``/v1/videos``. -A config JSON file is REQUIRED via --test-config-file: +A config JSON file may be passed via --test-config-file. If omitted, every ``*.json`` under +``tests/dfx/perf/tests/`` is loaded and pytest ``-m`` filters by each case's ``mark``: + pytest run_diffusion_benchmark.py -m "diffusion" pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json Optional: ``--assert-baseline`` compares metrics to the ``baseline`` block in each benchmark entry (default: off). -All benchmark results for a session are consolidated into a single JSON file under -BENCHMARK_RESULT_DIR (override via the DIFFUSION_BENCHMARK_DIR environment variable). -Each entry in the file contains the test metadata (test_name, endpoint, benchmark_params, -timestamp) together with the raw metrics returned by the benchmark script. +Optional JSON field ``mark`` is applied as pytest marks on that case via +``pytest.param`` (e.g. ``"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, "full_model", "diffusion"]``). + +All benchmark results are written under BENCHMARK_RESULT_DIR (override via the +DIFFUSION_BENCHMARK_DIR environment variable). Each source JSON file gets one +aggregated ``diffusion_result_{config_stem}_{hardware}_{timestamp}.json`` (JSON array +of all runs from cases in that file). Bulk load without ``--test-config-file`` uses +the same per-file aggregation; ``-m`` only selects which cases run. """ import json @@ -35,8 +41,15 @@ import psutil import pytest from benchmarks.diffusion.backends import endpoint_filename_token, normalize_endpoint - -pytestmark = [pytest.mark.diffusion, pytest.mark.full_model, pytest.mark.local_model] +from tests.dfx.conftest import ( + create_paired_benchmark_pytest_params, + get_runtime_resource_label, + hardware_json_value, + is_diffusion_perf_config, + resolve_pytest_marks, + resource_label_for_filename, +) +from tests.helpers.runtime import get_open_port os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" os.environ.setdefault("DIFFUSION_ATTENTION_BACKEND", "FLASH_ATTN") @@ -126,6 +139,10 @@ _BRANCHPOINT_COMMIT_SHA: str | None = None DIFFUSION_RESULT_TEMPLATE_PATH = Path(__file__).parent / "diffusion_result_template.json" +_DIFFUSION_SOURCE_CONFIG_KEY = "_source_config_file" +_PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests" + + def _get_config_file_from_argv() -> str | None: """Read --test-config-file from sys.argv at import time so pytest parametrize can use it. @@ -142,9 +159,6 @@ def _get_config_file_from_argv() -> str | None: CONFIG_FILE_PATH = _get_config_file_from_argv() -if CONFIG_FILE_PATH is None: - print("No config file provided, using default config file: tests/dfx/perf/tests/test_qwen_image_vllm_omni.json") - CONFIG_FILE_PATH = "tests/dfx/perf/tests/test_qwen_image_vllm_omni.json" # --------------------------------------------------------------------------- # Config loading @@ -184,28 +198,85 @@ def load_configs(config_path: str) -> list[dict[str, Any]]: raise RuntimeError(f"Failed to load configuration file: {str(e)}") -BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH) - -_config_stem = Path(CONFIG_FILE_PATH).stem # e.g. "test_qwen_image_vllm_omni" -AGGREGATED_RESULT_FILE = BENCHMARK_RESULT_DIR / f"diffusion_result_{_config_stem}_{_SESSION_TIMESTAMP}.json" +def load_diffusion_benchmark_configs( + config_path: str | None = None, + *, + config_dir: Path | None = None, +) -> list[dict[str, Any]]: + """Load one diffusion benchmark JSON, or merge all ``*.json`` under *config_dir*.""" + if config_path is not None: + configs = load_configs(config_path) + source = str(Path(config_path).resolve()) + for cfg in configs: + cfg.setdefault(_DIFFUSION_SOURCE_CONFIG_KEY, source) + return configs + if config_dir is None: + raise ValueError("load_diffusion_benchmark_configs requires config_path or config_dir") + configs: list[dict[str, Any]] = [] + for path in sorted(config_dir.glob("*.json")): + source = str(path.resolve()) + for cfg in load_configs(str(path)): + cfg[_DIFFUSION_SOURCE_CONFIG_KEY] = source + configs.append(cfg) + if not configs: + raise ValueError(f"No benchmark JSON files found under {config_dir}") + return configs -def _append_to_aggregated_file(record: dict[str, Any]) -> None: - """Thread-safe append of *record* to the session-level aggregated JSON file. +if CONFIG_FILE_PATH is None: + _all_configs = load_diffusion_benchmark_configs(config_dir=_PERF_TESTS_DIR) + BENCHMARK_CONFIGS = [cfg for cfg in _all_configs if is_diffusion_perf_config(cfg)] + print( + f"No --test-config-file: loaded {len(BENCHMARK_CONFIGS)} diffusion case(s) from " + f"{_PERF_TESTS_DIR}/*.json (skipped {len(_all_configs) - len(BENCHMARK_CONFIGS)} omni/tts; " + f"use -m to filter, e.g. -m diffusion)" + ) +else: + BENCHMARK_CONFIGS = load_diffusion_benchmark_configs(CONFIG_FILE_PATH) - The file contains a JSON array; each call loads the existing array (or - starts a new one), appends the record, and writes the file back atomically. - """ +_AGGREGATED_RESULT_FILES_BY_SOURCE: dict[str, Path] = {} + + +def _normalized_source_path(source_file: str) -> str: + return str(Path(source_file).resolve()) + + +def _aggregated_result_file_for_source(source_file: str) -> Path: + """One session aggregate per source JSON (same naming as single ``--test-config-file``).""" + key = _normalized_source_path(source_file) + if key not in _AGGREGATED_RESULT_FILES_BY_SOURCE: + stem = Path(key).stem + resource = resource_label_for_filename(get_runtime_resource_label()) + if resource: + result_name = f"diffusion_result_{stem}_{resource}_{_SESSION_TIMESTAMP}.json" + else: + result_name = f"diffusion_result_{stem}_{_SESSION_TIMESTAMP}.json" + _AGGREGATED_RESULT_FILES_BY_SOURCE[key] = BENCHMARK_RESULT_DIR / result_name + return _AGGREGATED_RESULT_FILES_BY_SOURCE[key] + + +def _write_result_record(record: dict[str, Any]) -> Path: + """Append one benchmark record to the aggregate file for its source JSON.""" + source_file = record.get("source_file") or CONFIG_FILE_PATH + if not source_file: + raise ValueError("benchmark record missing source_file") + target = _aggregated_result_file_for_source(str(source_file)) with _RESULT_LOCK: BENCHMARK_RESULT_DIR.mkdir(parents=True, exist_ok=True) - if AGGREGATED_RESULT_FILE.exists(): - with open(AGGREGATED_RESULT_FILE, encoding="utf-8") as f: + if target.exists(): + with open(target, encoding="utf-8") as f: records: list[dict] = json.load(f) else: records = [] records.append(record) - with open(AGGREGATED_RESULT_FILE, "w", encoding="utf-8") as f: + with open(target, "w", encoding="utf-8") as f: json.dump(records, f, indent=2, ensure_ascii=False) + return target + + +def _append_to_aggregated_file(record: dict[str, Any]) -> None: + """Backward-compatible wrapper; prefer :func:`_write_result_record`.""" + _write_result_record(record) _server_lock = threading.Lock() @@ -215,14 +286,6 @@ _server_lock = threading.Lock() # --------------------------------------------------------------------------- -def _get_open_port() -> int: - """Return an available TCP port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("", 0)) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - return s.getsockname()[1] - - def _wait_for_port(host: str, port: int, timeout: int = 1200, proc: subprocess.Popen | None = None) -> None: """Block until the given host:port accepts connections or timeout expires. @@ -312,7 +375,7 @@ class DiffusionServer: self.model = server_cfg["model"] self.serve_args = server_cfg["serve_args"] self.host = "127.0.0.1" - self.port = port if port is not None else _get_open_port() + self.port = port if port is not None else get_open_port(self.host) self.proc: subprocess.Popen | None = None self.test_name: str = "" @@ -482,6 +545,7 @@ def _unique_server_params(configs: list[dict[str, Any]]) -> list[dict[str, Any]] "serve_args": _build_serve_args(serve_args_dict), "benchmark_endpoint": cfg.get("benchmark_endpoint", cfg.get("benchmark_backend")), "server_params": cfg["server_params"], + "mark": cfg.get("mark"), } ) return result @@ -496,19 +560,29 @@ def _test_param_mapping(configs: list[dict[str, Any]]) -> dict[str, list[dict]]: return mapping -def _make_server(server_cfg: dict[str, Any]) -> DiffusionServer: - """Factory: return a vLLM-Omni diffusion server instance for the config.""" - return DiffusionServer(server_cfg=server_cfg) +def _marks_by_test_name(configs: list[dict[str, Any]]) -> dict[str, list[pytest.MarkDecorator]]: + return {str(cfg["test_name"]): resolve_pytest_marks(cfg.get("mark")) for cfg in configs} + + +def _paired_diffusion_benchmark_pytest_params(configs: list[dict[str, Any]]) -> list[Any]: + """Paired params for ``run_diffusion_benchmark.py``; same shape as omni runner.""" + test_param_map = _test_param_mapping(configs) + server_entries = [(cfg, cfg["test_name"]) for cfg in _unique_server_params(configs)] + return create_paired_benchmark_pytest_params(server_entries, test_param_map, _marks_by_test_name(configs)) # --------------------------------------------------------------------------- # Parametrize data # --------------------------------------------------------------------------- -server_params = _unique_server_params(BENCHMARK_CONFIGS) test_param_map = _test_param_mapping(BENCHMARK_CONFIGS) +paired_benchmark_params = _paired_diffusion_benchmark_pytest_params(BENCHMARK_CONFIGS) + + +def _make_server(server_cfg: dict[str, Any]) -> DiffusionServer: + """Factory: return a vLLM-Omni diffusion server instance for the config.""" + return DiffusionServer(server_cfg=server_cfg) -benchmark_indices: list[int] = list(range(max(len(v) for v in test_param_map.values()))) # --------------------------------------------------------------------------- # Pytest fixtures @@ -534,16 +608,13 @@ def diffusion_server(request): @pytest.fixture -def benchmark_params(request, diffusion_server): - """Yield the benchmark params dict for the current (server, index) pair.""" - param_index: int = request.param - test_name = diffusion_server.test_name +def benchmark_params(request): + """Benchmark params for the paired server/index parametrization.""" + test_name, param_index = request.param params_list = test_param_map.get(test_name, []) if not params_list: raise ValueError(f"No benchmark params for test: {test_name}") - if param_index >= len(params_list): - pytest.skip(f"Param index {param_index} out of range for {test_name} (has {len(params_list)} params)") current = param_index + 1 total = len(params_list) @@ -575,8 +646,8 @@ def run_benchmark( The raw metrics are written to a temporary file by the subprocess. After the run completes the metrics are merged with full metadata (test_name, endpoint, benchmark_params, timestamp, flat reporting fields) and appended - to the session-wide aggregated JSON file (AGGREGATED_RESULT_FILE). The - temporary file is removed afterwards. Subprocess stdout/stderr are tee'd + to ``diffusion_result_{config_stem}_{hardware}_{timestamp}.json`` for the + source JSON file. The temporary file is removed afterwards. Subprocess stdout/stderr are tee'd to a .log file under BENCHMARK_RESULT_DIR/logs/; its path is stored in the record. """ @@ -586,7 +657,12 @@ def run_benchmark( log_dir = BENCHMARK_RESULT_DIR / "logs" log_dir.mkdir(parents=True, exist_ok=True) endpoint_label = endpoint_filename_token(endpoint) - log_file = log_dir / f"{test_name}_{endpoint_label}_{timestamp}.log" + resource_label = get_runtime_resource_label() + hw_for_filename = resource_label_for_filename(resource_label) + if hw_for_filename: + log_file = log_dir / f"{test_name}_{hw_for_filename}_{endpoint_label}_{timestamp}.log" + else: + log_file = log_dir / f"{test_name}_{endpoint_label}_{timestamp}.log" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp: tmp_result_file = Path(tmp.name) @@ -696,7 +772,7 @@ def run_benchmark( "Model": model, "Framework": server_type, "API Endpoint": endpoint, - "Hardware": "", + "Hardware": hardware_json_value(resource_label), "Deployment": "", "Task": params.get("task", "t2i"), "Dataset": params.get("dataset", "random"), @@ -728,8 +804,8 @@ def run_benchmark( "build_url": os.environ.get("BUILDKITE_BUILD_URL", ""), "source_file": source_file, } - _append_to_aggregated_file(record) - print(f"\n Result appended to: {AGGREGATED_RESULT_FILE}") + result_path = _write_result_record(record) + print(f"\n Result saved to: {result_path}") print(f" Log saved to: {log_file}") return metrics @@ -932,12 +1008,10 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]: # --------------------------------------------------------------------------- @pytest.mark.benchmark @pytest.mark.parametrize( - "diffusion_server", - server_params, - ids=[p["test_name"] for p in server_params], - indirect=True, + "diffusion_server,benchmark_params", + paired_benchmark_params, + indirect=["diffusion_server", "benchmark_params"], ) -@pytest.mark.parametrize("benchmark_params", benchmark_indices, indirect=True) def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, request): """Run the diffusion performance benchmark and verify request completion. @@ -962,7 +1036,10 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req test_name=test_name, endpoint=endpoint, server_cfg=server_cfg, - source_file=cast(str, CONFIG_FILE_PATH), + source_file=server_cfg.get( + _DIFFUSION_SOURCE_CONFIG_KEY, + CONFIG_FILE_PATH or f"{_PERF_TESTS_DIR}/*.json", + ), ) print(f"\n{'=' * 60}") @@ -980,7 +1057,9 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req if key in result: print(f" {key}: {result[key]:.4f}") - print(f"\n Aggregated results: {AGGREGATED_RESULT_FILE}") + source = server_cfg.get(_DIFFUSION_SOURCE_CONFIG_KEY) or CONFIG_FILE_PATH + if source: + print(f"\n Aggregated results: {_aggregated_result_file_for_source(str(source))}") print("=" * 60) assert_baseline = request.config.getoption("--assert-baseline", default=False) diff --git a/tests/dfx/perf/tests/test_bagel_vllm_omni.json b/tests/dfx/perf/tests/test_bagel_vllm_omni.json index 09fc96030..303839387 100644 --- a/tests/dfx/perf/tests/test_bagel_vllm_omni.json +++ b/tests/dfx/perf/tests/test_bagel_vllm_omni.json @@ -1,6 +1,18 @@ [ { "test_name": "test_bagel_single_device_single_stage_t2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Single-stage BAGEL (TP=1, CFG=1), t2i Task", "server_type": "vllm-omni", "server_params": { @@ -38,6 +50,18 @@ }, { "test_name": "test_bagel_single_device_single_stage_i2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Single-stage BAGEL (TP=1, CFG=1), i2i Task", "server_type": "vllm-omni", "server_params": { @@ -76,6 +100,18 @@ }, { "test_name": "test_bagel_single_device_multi_stage_t2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Two-stage BAGEL (bagel.yaml), TP=1, CFG=1, t2i Task", "server_type": "vllm-omni", "server_params": { @@ -113,6 +149,18 @@ }, { "test_name": "test_bagel_single_device_multi_stage_i2i", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], "description": "Two-stage BAGEL (bagel.yaml), TP=1, CFG=1, i2i Task", "server_type": "vllm-omni", "server_params": { diff --git a/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json index b0fc9a5a4..9bf31658d 100644 --- a/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json +++ b/tests/dfx/perf/tests/test_cosmos3_vllm_omni.json @@ -1,213 +1,261 @@ [ - { - "test_name": "test_cosmos3_t2i_official_demo_2gpu", - "description": "Cosmos3-Nano text-to-image official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/images/generations", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps4", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 4, - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "extra-body": { - "guidance_scale": 7.0, - "flow_shift": 3.0, - "guardrails": false - }, - "baseline": { - "throughput_qps": 1.107, - "latency_mean": 0.8929, - "peak_memory_mb_mean": 80000.0 - } - } - ] + { + "test_name": "test_cosmos3_t2i_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano text-to-image official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/generations", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_t2v_official_demo_2gpu", - "description": "Cosmos3-Nano text-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1024x1024_steps4", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 4, + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "extra-body": { + "guidance_scale": 7.0, + "flow_shift": 3.0, + "guardrails": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "t2v", - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "A robot arm is cleaning a plate in the kitchen.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false, - "use_resolution_template": false, - "use_duration_template": false - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 36.6824, - "peak_memory_mb_mean": 30155.4 - } - } - ] + "baseline": { + "throughput_qps": 1.107, + "latency_mean": 0.8929, + "peak_memory_mb_mean": 80000.0 + } + } + ] + }, + { + "test_name": "test_cosmos3_t2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano text-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_i2v_official_demo_2gpu", - "description": "Cosmos3-Nano image-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "t2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "A robot arm is cleaning a plate in the kitchen.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "use_resolution_template": false, + "use_duration_template": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 3, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "The scene comes to life with smooth, natural motion.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 36.968, - "peak_memory_mb_mean": 30443.6 - } - } - ] + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 36.6824, + "peak_memory_mb_mean": 30155.4 + } + } + ] + }, + { + "test_name": "test_cosmos3_i2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano image-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_cosmos3_v2v_official_demo_2gpu", - "description": "Cosmos3-Nano video-to-video official demo workload on 2 GPUs", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/videos", - "server_params": { - "model": "nvidia/Cosmos3-Nano", - "serve_args": { - "cfg-parallel-size": 2, - "use-hsdp": true, - "hsdp-shard-size": 2, - "vae-patch-parallel-size": 2, - "vae-use-tiling": true, - "model-class-name": "Cosmos3OmniDiffusersPipeline", - "no-guardrails": true, - "stage-init-timeout": 900, - "init-timeout": 1200, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 3, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "The scene comes to life with smooth, natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false }, - "benchmark_params": [ - { - "name": "1280x720_frames189_steps4", - "dataset": "random", - "task": "v2v", - "num-prompts": 3, - "max-concurrency": 1, - "seed": 42, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "prompt": "Continue the same scene with smooth natural motion.", - "num_inference_steps": 4, - "num_frames": 189, - "fps": 24, - "weight": 1 - } - ], - "extra-body": { - "guidance_scale": 6.0, - "flow_shift": 10.0, - "max_sequence_length": 4096, - "guardrails": false, - "condition_frame_indexes_vision": [ - 0, - 1 - ], - "condition_video_keep": "first" - }, - "baseline": { - "throughput_qps": 0.027, - "latency_mean": 37.2117, - "peak_memory_mb_mean": 30135.6 - } - } - ] - } + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 36.968, + "peak_memory_mb_mean": 30443.6 + } + } + ] + }, + { + "test_name": "test_cosmos3_v2v_official_demo_2gpu", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "Cosmos3-Nano video-to-video official demo workload on 2 GPUs", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/videos", + "server_params": { + "model": "nvidia/Cosmos3-Nano", + "serve_args": { + "cfg-parallel-size": 2, + "use-hsdp": true, + "hsdp-shard-size": 2, + "vae-patch-parallel-size": 2, + "vae-use-tiling": true, + "model-class-name": "Cosmos3OmniDiffusersPipeline", + "no-guardrails": true, + "stage-init-timeout": 900, + "init-timeout": 1200, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1280x720_frames189_steps4", + "dataset": "random", + "task": "v2v", + "num-prompts": 3, + "max-concurrency": 1, + "seed": 42, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "prompt": "Continue the same scene with smooth natural motion.", + "num_inference_steps": 4, + "num_frames": 189, + "fps": 24, + "weight": 1 + } + ], + "extra-body": { + "guidance_scale": 6.0, + "flow_shift": 10.0, + "max_sequence_length": 4096, + "guardrails": false, + "condition_frame_indexes_vision": [ + 0, + 1 + ], + "condition_video_keep": "first" + }, + "baseline": { + "throughput_qps": 0.027, + "latency_mean": 37.2117, + "peak_memory_mb_mean": 30135.6 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_higgs_audio_v3.json b/tests/dfx/perf/tests/test_higgs_audio_v3.json index 6fdaec580..9b7b2eb5c 100644 --- a/tests/dfx/perf/tests/test_higgs_audio_v3.json +++ b/tests/dfx/perf/tests/test_higgs_audio_v3.json @@ -1,6 +1,18 @@ [ { "test_name": "test_higgs_audio_v3", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "bosonai/higgs-audio-v3-tts-4b", "stage_overrides": { diff --git a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json index fa955c763..1b6902cba 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json +++ b/tests/dfx/perf/tests/test_hunyuan_image3_it2i.json @@ -1,140 +1,152 @@ [ - { - "test_name": "test_hunyuan_image3_ti2i_inline", - "description": "AR (TP=2) + 2 * DiT (TP=2) - using inline dataset and deploy-config", - "server_type": "vllm-omni", - "benchmark_endpoint": "/v1/images/edits", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "trust-remote-code": true, - "deploy-config-inline": { - "type": "yaml", - "content": { - "pipeline": "hunyuan_image_3_moe", - "async_chunk": false, - "trust_remote_code": true, - "connectors": { - "rdma_connector": { - "name": "MooncakeTransferEngineConnector", - "extra": { - "host": "auto", - "zmq_port": 50051, - "protocol": "rdma", - "device_name": "", - "memory_pool_size": 17179869184, - "memory_pool_device": "cpu" - } - } - }, - "stages": [ - { - "stage_id": 0, - "is_comprehension": true, - "final_output": true, - "final_output_type": "text", - "max_num_seqs": 16, - "gpu_memory_utilization": 0.95, - "enforce_eager": false, - "enable_prefix_caching": false, - "max_num_batched_tokens": 32768, - "devices": "0,1", - "tensor_parallel_size": 2, - "hf_overrides": { - "rope_parameters": { - "mrope_section": [ - 0, - 32, - 32 - ], - "rope_type": "default" - } - }, - "omni_kv_config": { - "need_send_cache": true - }, - "output_connectors": { - "to_stage_1": "rdma_connector" - }, - "default_sampling_params": { - "temperature": 0.0, - "top_p": 1, - "top_k": -1, - "max_tokens": 8192, - "detokenize": true, - "skip_special_tokens": false, - "stop_token_ids": [ - 128025 - ] - } - }, - { - "stage_id": 1, - "max_num_seqs": 1, - "enforce_eager": true, - "devices": "2,3", - "distributed_executor_backend": "mp", - "omni_kv_config": { - "need_recv_cache": true - }, - "parallel_config": { - "tensor_parallel_size": 2, - "vae_patch_parallel_size": 2, - "enable_expert_parallel": true - }, - "input_connectors": { - "from_stage_0": "rdma_connector" - }, - "default_sampling_params": { - "num_inference_steps": 50, - "guidance_scale": 0 - } - } - ], - "edges": [ - { - "from": 0, - "to": 1, - "window_size": -1, - "max_inflight": 1 - } - ] - } - }, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "inline_dataset_5_requests", - "dataset": "custom", - "dataset-path-inline": { - "type": "jsonl", - "content": [ - { - "prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。", - "image_urls": [ - "https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png" - ], - "num_inference_steps": 8, - "seed": 42, - "guidance_scale": 1.0 - } - ] - }, - "task": "ti2i", - "bot-task": "think_recaption", - "max-concurrency": 16, - "num-prompts": 96, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.1874, - "latency_p99": 101.852, - "latency_mean": 79.0385, - "peak_memory_mb_max": 70000, - "peak_memory_mb_mean": 70000 + { + "test_name": "test_hunyuan_image3_ti2i_inline", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "AR (TP=2) + 2 * DiT (TP=2) - using inline dataset and deploy-config", + "server_type": "vllm-omni", + "benchmark_endpoint": "/v1/images/edits", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "trust-remote-code": true, + "deploy-config-inline": { + "type": "yaml", + "content": { + "pipeline": "hunyuan_image_3_moe", + "async_chunk": false, + "trust_remote_code": true, + "connectors": { + "rdma_connector": { + "name": "MooncakeTransferEngineConnector", + "extra": { + "host": "auto", + "zmq_port": 50051, + "protocol": "rdma", + "device_name": "", + "memory_pool_size": 17179869184, + "memory_pool_device": "cpu" } + } + }, + "stages": [ + { + "stage_id": 0, + "is_comprehension": true, + "final_output": true, + "final_output_type": "text", + "max_num_seqs": 16, + "gpu_memory_utilization": 0.95, + "enforce_eager": false, + "enable_prefix_caching": false, + "max_num_batched_tokens": 32768, + "devices": "0,1", + "tensor_parallel_size": 2, + "hf_overrides": { + "rope_parameters": { + "mrope_section": [ + 0, + 32, + 32 + ], + "rope_type": "default" + } + }, + "omni_kv_config": { + "need_send_cache": true + }, + "output_connectors": { + "to_stage_1": "rdma_connector" + }, + "default_sampling_params": { + "temperature": 0.0, + "top_p": 1, + "top_k": -1, + "max_tokens": 8192, + "detokenize": true, + "skip_special_tokens": false, + "stop_token_ids": [ + 128025 + ] + } + }, + { + "stage_id": 1, + "max_num_seqs": 1, + "enforce_eager": true, + "devices": "2,3", + "distributed_executor_backend": "mp", + "omni_kv_config": { + "need_recv_cache": true + }, + "parallel_config": { + "tensor_parallel_size": 2, + "vae_patch_parallel_size": 2, + "enable_expert_parallel": true + }, + "input_connectors": { + "from_stage_0": "rdma_connector" + }, + "default_sampling_params": { + "num_inference_steps": 50, + "guidance_scale": 0 + } + } + ], + "edges": [ + { + "from": 0, + "to": 1, + "window_size": -1, + "max_inflight": 1 + } + ] + } + }, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "inline_dataset_5_requests", + "dataset": "custom", + "dataset-path-inline": { + "type": "jsonl", + "content": [ + { + "prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。", + "image_urls": [ + "https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png" + ], + "num_inference_steps": 8, + "seed": 42, + "guidance_scale": 1.0 } - ] - } + ] + }, + "task": "ti2i", + "bot-task": "think_recaption", + "max-concurrency": 16, + "num-prompts": 96, + "skip-performance-assertion": true, + "baseline": { + "throughput_qps": 0.1874, + "latency_p99": 101.852, + "latency_mean": 79.0385, + "peak_memory_mb_max": 70000, + "peak_memory_mb_mean": 70000 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json b/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json index e6d7ce659..7a699f53a 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp2_cfgp2.json @@ -1,35 +1,47 @@ [ - { - "test_name": "test_hunyuan_image_tp2_cfgp2", - "description": "TP=2 CfgP=2 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy_config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 2, - "cfg-parallel-size": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.21, - "latency_p99": 4.7469, - "peak_memory_mb_max": 101100, - "peak_memory_mb_mean": 101100 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp2_cfgp2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=2 CfgP=2 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy_config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 2, + "cfg-parallel-size": 2, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "skip-performance-assertion": true, + "baseline": { + "throughput_qps": 0.21, + "latency_p99": 4.7469, + "peak_memory_mb_max": 101100, + "peak_memory_mb_mean": 101100 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json b/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json index fbbe1643e..521febd2d 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp2_sp2.json @@ -1,35 +1,47 @@ [ - { - "test_name": "test_hunyuan_image_tp2_sp2", - "description": "TP=2 SP=2 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 2, - "ulysses-degree": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.20, - "latency_p99": 5.1025, - "peak_memory_mb_max": 97402, - "peak_memory_mb_mean": 97402 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp2_sp2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=2 SP=2 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 2, + "ulysses-degree": 2, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "skip-performance-assertion": true, + "baseline": { + "throughput_qps": 0.20, + "latency_p99": 5.1025, + "peak_memory_mb_max": 97402, + "peak_memory_mb_mean": 97402 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_hunyuan_image_tp4.json b/tests/dfx/perf/tests/test_hunyuan_image_tp4.json index eb37f7a22..f2d05564b 100644 --- a/tests/dfx/perf/tests/test_hunyuan_image_tp4.json +++ b/tests/dfx/perf/tests/test_hunyuan_image_tp4.json @@ -1,35 +1,47 @@ [ - { - "test_name": "test_hunyuan_image_tp4", - "description": "TP=4 baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "tencent/HunyuanImage-3.0-Instruct", - "serve_args": { - "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", - "tensor-parallel-size": 4, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1024x1024_steps8", - "dataset": "random", - "task": "t2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 8, - "num-prompts": 10, - "max-concurrency": 1, - "skip-performance-assertion": true, - "baseline": { - "throughput_qps": 0.213, - "latency_mean": 4.6953, - "latency_p99": 4.8601, - "peak_memory_mb_max": 56912.0, - "peak_memory_mb_mean": 56912.0 - } - } - ] - } + { + "test_name": "test_hunyuan_image_tp4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "diffusion", + "local_model" + ], + "description": "TP=4 baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "tencent/HunyuanImage-3.0-Instruct", + "serve_args": { + "deploy-config": "../vllm_omni/deploy/hunyuan_image3_dit.yaml", + "tensor-parallel-size": 4, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "1024x1024_steps8", + "dataset": "random", + "task": "t2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 8, + "num-prompts": 10, + "max-concurrency": 1, + "skip-performance-assertion": true, + "baseline": { + "throughput_qps": 0.213, + "latency_mean": 4.6953, + "latency_p99": 4.8601, + "peak_memory_mb_max": 56912.0, + "peak_memory_mb_mean": 56912.0 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_ltx2_vllm_omni.json b/tests/dfx/perf/tests/test_ltx2_vllm_omni.json index 4a6f9e350..1a0f31e98 100644 --- a/tests/dfx/perf/tests/test_ltx2_vllm_omni.json +++ b/tests/dfx/perf/tests/test_ltx2_vllm_omni.json @@ -1,217 +1,273 @@ [ - { - "test_name": "test_ltx2_baseline_eager", - "description": "Single-device baseline with enforce-eager (no torch.compile)", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + { + "test_name": "test_ltx2_baseline_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline with enforce-eager (no torch.compile)", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_torch_compile", - "description": "Single-device with torch.compile (default, no enforce-eager)", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_torch_compile", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device with torch.compile (default, no enforce-eager)", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cfg2_eager", - "description": "CFG-parallel=2 with enforce-eager", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cfg-parallel-size": 2, - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cfg2_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "CFG-parallel=2 with enforce-eager", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cfg-parallel-size": 2, + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cfg2_compile", - "description": "CFG-parallel=2 with torch.compile", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cfg-parallel-size": 2, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cfg2_compile", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "CFG-parallel=2 with torch.compile", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cfg-parallel-size": 2, + "enable-diffusion-pipeline-profiler": true + } }, - - { - "test_name": "test_ltx2_cache_dit_eager", - "description": "CacheDiT with enforce-eager", - "server_type": "vllm-omni", - "server_params": { - "model": "Lightricks/LTX-2", - "serve_args": { - "cache-backend": "cache_dit", - "enforce-eager": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "256x256_145f_steps6", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 256, - "height": 256, - "num-frames": 145, - "fps": 24, - "num-inference-steps": 6, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - }, - { - "name": "480x768_41f_steps20", - "dataset": "random", - "task": "t2v", - "backend": "v1/videos", - "width": 768, - "height": 480, - "num-frames": 41, - "fps": 24, - "num-inference-steps": 20, - "num-prompts": 3, - "max-concurrency": 1, - "enable-negative-prompt": true - } - ] - } + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + }, + { + "test_name": "test_ltx2_cache_dit_eager", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "CacheDiT with enforce-eager", + "server_type": "vllm-omni", + "server_params": { + "model": "Lightricks/LTX-2", + "serve_args": { + "cache-backend": "cache_dit", + "enforce-eager": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "256x256_145f_steps6", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 256, + "height": 256, + "num-frames": 145, + "fps": 24, + "num-inference-steps": 6, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + }, + { + "name": "480x768_41f_steps20", + "dataset": "random", + "task": "t2v", + "backend": "v1/videos", + "width": 768, + "height": 480, + "num-frames": 41, + "fps": 24, + "num-inference-steps": 20, + "num-prompts": 3, + "max-concurrency": 1, + "enable-negative-prompt": true + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json b/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json index 459554f63..66ffa231f 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json @@ -1,331 +1,343 @@ [ - { - "test_name": "test_qwen3_omni_chunk", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "extra_cli_args": [ - "--async-chunk" - ] + { + "test_name": "test_qwen3_omni_chunk", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "extra_cli_args": [ + "--async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 135.4379, + 187.0896, + 400, + 435.675, + 724.6836 + ], + "mean_e2el_ms": [ + 17619.9851, + 34811.8335, + 43656.9501, + 56657.1548, + 85097.6016 + ], + "mean_audio_rtf": [ + 0.1279, + 0.2238, + 0.2735, + 0.3712, + 0.5634 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 135.4379, - 187.0896, - 400, - 435.675, - 724.6836 - ], - "mean_e2el_ms": [ - 17619.9851, - 34811.8335, - 43656.9501, - 56657.1548, - 85097.6016 - ], - "mean_audio_rtf": [ - 0.1279, - 0.2238, - 0.2735, - 0.3712, - 0.5634 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 340.329 - ], - "mean_e2el_ms": [ - 4305.477 - ], - "mean_audio_rtf": [ - 0.1305 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 220.0909 - ], - "mean_e2el_ms": [ - 5475.9565 - ], - "mean_audio_rtf": [ - 0.1818 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 450.0155 - ], - "mean_e2el_ms": [ - 8516.8316 - ], - "mean_audio_rtf": [ - 0.2717 - ] - } - }, - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 78.4278, - 195.8037, - 333.7318, - 561.805, - 1038.043 - ], - "mean_e2el_ms": [ - 4288.3029, - 7429.9429, - 9451.6184, - 12607.5292, - 18153.2547 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 275.3875 - ], - "mean_e2el_ms": [ - 730.6995 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 166.2201 - ], - "mean_e2el_ms": [ - 673.5789 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 357.7781 - ], - "mean_e2el_ms": [ - 953.3493 - ] - } - } - ] - } + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 340.329 + ], + "mean_e2el_ms": [ + 4305.477 + ], + "mean_audio_rtf": [ + 0.1305 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 220.0909 + ], + "mean_e2el_ms": [ + 5475.9565 + ], + "mean_audio_rtf": [ + 0.1818 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 450.0155 + ], + "mean_e2el_ms": [ + 8516.8316 + ], + "mean_audio_rtf": [ + 0.2717 + ] + } + }, + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 78.4278, + 195.8037, + 333.7318, + 561.805, + 1038.043 + ], + "mean_e2el_ms": [ + 4288.3029, + 7429.9429, + 9451.6184, + 12607.5292, + 18153.2547 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 + }, + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 275.3875 + ], + "mean_e2el_ms": [ + 730.6995 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 166.2201 + ], + "mean_e2el_ms": [ + 673.5789 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 357.7781 + ], + "mean_e2el_ms": [ + 953.3493 + ] + } + } + ] + } ] 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 bee799a77..ac6f679c1 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_multi_replicas.json @@ -1,115 +1,127 @@ [ - { - "test_name": "test_qwen3_omni_3gpu_replica2", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "stage_overrides": { - "1": {"num_replicas": 2, "devices": "1,2"}, - "2": {"num_replicas": 2, "devices": "1,2"} - }, - "extra_cli_args": [ - "--async-chunk" - ] + { + "test_name": "test_qwen3_omni_3gpu_replica2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 3 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "stage_overrides": { + "1": {"num_replicas": 2, "devices": "1,2"}, + "2": {"num_replicas": 2, "devices": "1,2"} + }, + "extra_cli_args": [ + "--async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [128, 128, 128, 128], + "max_concurrency": [8, 16, 24, 32], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "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] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [10], + "request_rate": [0.1], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [128, 128, 128, 128], - "max_concurrency": [8, 16, 24, 32], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "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] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [10], - "request_rate": [0.1], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [377], - "mean_audio_ttfp_ms": [684], - "mean_audio_rtf": [0.14] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [40], - "request_rate": [0.5], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [228], - "mean_audio_ttfp_ms": [686], - "mean_audio_rtf": [0.16] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [100], - "request_rate": [1.0], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [522], - "mean_audio_ttfp_ms": [1119], - "mean_audio_rtf": [0.21] - } - } - ] - } + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [377], + "mean_audio_ttfp_ms": [684], + "mean_audio_rtf": [0.14] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [40], + "request_rate": [0.5], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [228], + "mean_audio_ttfp_ms": [686], + "mean_audio_rtf": [0.16] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [100], + "request_rate": [1.0], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [522], + "mean_audio_ttfp_ms": [1119], + "mean_audio_rtf": [0.21] + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json b/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json index d01ca06b4..78daf691b 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_no_async_chunk.json @@ -1,170 +1,182 @@ [ - { - "test_name": "test_qwen3_omni", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "extra_cli_args": [ - "--no-async-chunk" - ] + { + "test_name": "test_qwen3_omni", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "extra_cli_args": [ + "--no-async-chunk" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 109.1218, + 247.4411, + 354.9384, + 447.7885, + 694.7487 + ], + "mean_e2el_ms": [ + 22413.667, + 30808.9715, + 35677.844, + 46345.8978, + 68350.4853 + ], + "mean_audio_rtf": [ + 0.1695, + 0.2033, + 0.2467, + 0.3234, + 0.4438 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 109.1218, - 247.4411, - 354.9384, - 447.7885, - 694.7487 - ], - "mean_e2el_ms": [ - 22413.667, - 30808.9715, - 35677.844, - 46345.8978, - 68350.4853 - ], - "mean_audio_rtf": [ - 0.1695, - 0.2033, - 0.2467, - 0.3234, - 0.4438 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 323.3573 - ], - "mean_e2el_ms": [ - 4623.4173 - ], - "mean_audio_rtf": [ - 0.1406 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 215.4502 - ], - "mean_e2el_ms": [ - 4924.149 - ], - "mean_audio_rtf": [ - 0.1637 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "openai-chat-omni", - "endpoint": "/v1/chat/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", - "baseline": { - "mean_ttft_ms": [ - 442.5815 - ], - "mean_e2el_ms": [ - 6441.0986 - ], - "mean_audio_rtf": [ - 0.2091 - ] - } - } - ] - } + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 323.3573 + ], + "mean_e2el_ms": [ + 4623.4173 + ], + "mean_audio_rtf": [ + 0.1406 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 215.4502 + ], + "mean_e2el_ms": [ + 4924.149 + ], + "mean_audio_rtf": [ + 0.1637 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_ttft_ms": [ + 442.5815 + ], + "mean_e2el_ms": [ + 6441.0986 + ], + "mean_audio_rtf": [ + 0.2091 + ] + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json b/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json index f608eca0e..b9d553098 100644 --- a/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json +++ b/tests/dfx/perf/tests/test_qwen3_omni_vllm_text.json @@ -1,180 +1,192 @@ [ - { - "test_name": "test_qwen3_omni_vllm_text", - "server_params": { - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "use_omni": false, - "extra_cli_args": [ - "--no-enable-prefix-caching", - "--skip-mm-profiling" - ] + { + "test_name": "test_qwen3_omni_vllm_text", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "omni" + ], + "server_params": { + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "use_omni": false, + "extra_cli_args": [ + "--no-enable-prefix-caching", + "--skip-mm-profiling" + ] + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 4, + 16, + 32, + 64, + 128 + ], + "max_concurrency": [ + 1, + 4, + 8, + 16, + 32 + ], + "random_input_len": 2500, + "random_output_len": 900, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] }, - "benchmark_params": [ - { - "dataset_name": "random", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 4, - 16, - 32, - 64, - 128 - ], - "max_concurrency": [ - 1, - 4, - 8, - 16, - 32 - ], - "random_input_len": 2500, - "random_output_len": 900, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 69.1276, - 255.1882, - 310.639, - 422.0337, - 532.0582 - ], - "mean_e2el_ms": [ - 4382.7211, - 7510.3815, - 9440.4854, - 12553.0186, - 16932.6065 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 10 - ], - "request_rate": [ - 0.1 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 1, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "audio": 1 - }, - "random_mm_bucket_config": { - "(0, 60, 3)": 1.0 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 26.3381 - ], - "mean_e2el_ms": [ - 485.2436 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 40 - ], - "request_rate": [ - 0.5 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 2, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.5, - "(720, 1280, 2)": 0.5 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 26.4517 - ], - "mean_e2el_ms": [ - 517.2017 - ] - } - }, - { - "dataset_name": "random-mm", - "backend": "vllm", - "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", - "endpoint": "/v1/completions", - "num_prompts": [ - 100 - ], - "request_rate": [ - 1.0 - ], - "random_input_len": 100, - "random_output_len": 100, - "random_range_ratio": 0.0, - "ignore_eos": true, - "extra_body": { - "modalities": [ - "text" - ] - }, - "random_mm_base_items_per_request": 3, - "random_mm_num_mm_items_range_ratio": 0.5, - "random_mm_limit_mm_per_prompt": { - "image": 1, - "video": 1, - "audio": 1 - }, - "random_mm_bucket_config": { - "(256, 256, 1)": 0.34, - "(720, 1280, 2)": 0.33, - "(0, 60, 3)": 0.33 - }, - "percentile-metrics": "ttft,tpot,itl,e2el", - "baseline": { - "mean_ttft_ms": [ - 27.6972 - ], - "mean_e2el_ms": [ - 561.3684 - ] - } - } - ] - } + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 69.1276, + 255.1882, + 310.639, + 422.0337, + 532.0582 + ], + "mean_e2el_ms": [ + 4382.7211, + 7510.3815, + 9440.4854, + 12553.0186, + 16932.6065 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 10 + ], + "request_rate": [ + 0.1 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 1, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "audio": 1 + }, + "random_mm_bucket_config": { + "(0, 60, 3)": 1.0 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 26.3381 + ], + "mean_e2el_ms": [ + 485.2436 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 40 + ], + "request_rate": [ + 0.5 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 2, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.5, + "(720, 1280, 2)": 0.5 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 26.4517 + ], + "mean_e2el_ms": [ + 517.2017 + ] + } + }, + { + "dataset_name": "random-mm", + "backend": "vllm", + "model": "Qwen/Qwen3-Omni-30B-A3B-Instruct", + "endpoint": "/v1/completions", + "num_prompts": [ + 100 + ], + "request_rate": [ + 1.0 + ], + "random_input_len": 100, + "random_output_len": 100, + "random_range_ratio": 0.0, + "ignore_eos": true, + "extra_body": { + "modalities": [ + "text" + ] + }, + "random_mm_base_items_per_request": 3, + "random_mm_num_mm_items_range_ratio": 0.5, + "random_mm_limit_mm_per_prompt": { + "image": 1, + "video": 1, + "audio": 1 + }, + "random_mm_bucket_config": { + "(256, 256, 1)": 0.34, + "(720, 1280, 2)": 0.33, + "(0, 60, 3)": 0.33 + }, + "percentile-metrics": "ttft,tpot,itl,e2el", + "baseline": { + "mean_ttft_ms": [ + 27.6972 + ], + "mean_e2el_ms": [ + 561.3684 + ] + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json index 118fa6733..53e13daed 100644 --- a/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_edit_2511_vllm_omni.json @@ -1,149 +1,185 @@ [ - { - "test_name": "test_qwen_image_edit_2511_single_device", - "description": "Single-device baseline (two input images)", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0721, - "latency_mean": 13.8398, - "peak_memory_mb_max": 54731.4, - "peak_memory_mb_mean": 54731.4 - } - }, - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0186, - "latency_mean": 53.6942, - "peak_memory_mb_max": 64402.4, - "peak_memory_mb_mean": 64402.4 - } - } - ] + { + "test_name": "test_qwen_image_edit_2511_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (two input images)", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0538, - "latency_mean": 18.5591, - "peak_memory_mb_max": 53485.0, - "peak_memory_mb_mean": 53485.0 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0721, + "latency_mean": 13.8398, + "peak_memory_mb_max": 54731.4, + "peak_memory_mb_mean": 54731.4 + } + }, + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0186, + "latency_mean": 53.6942, + "peak_memory_mb_max": 64402.4, + "peak_memory_mb_mean": 64402.4 + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG=2 + CacheDiT", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit-2511", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0538, + "latency_mean": 18.5591, + "peak_memory_mb_max": 53485.0, + "peak_memory_mb_mean": 53485.0 + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_2511_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + CacheDiT", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit-2511", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.2776, - "latency_mean": 3.5938, - "peak_memory_mb_max": 55214.0, - "peak_memory_mb_mean": 55214.0 - } - }, - { - "name": "1536x1536_steps35_i2i_2img", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 2, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1051, - "latency_mean": 9.4918, - "peak_memory_mb_max": 64265.6, - "peak_memory_mb_mean": 64265.6 - } - } - ] - } + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.2776, + "latency_mean": 3.5938, + "peak_memory_mb_max": 55214.0, + "peak_memory_mb_mean": 55214.0 + } + }, + { + "name": "1536x1536_steps35_i2i_2img", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 2, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.1051, + "latency_mean": 9.4918, + "peak_memory_mb_max": 64265.6, + "peak_memory_mb_mean": 64265.6 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json index da3288182..bfda30731 100644 --- a/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_edit_vllm_omni.json @@ -1,144 +1,180 @@ [ - { - "test_name": "test_qwen_image_edit_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1129, - "latency_mean": 8.8352, - "peak_memory_mb_max": 55356.5, - "peak_memory_mb_mean": 55356.5 - } - }, - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0240, - "latency_mean": 41.5686, - "peak_memory_mb_max": 62762.7, - "peak_memory_mb_mean": 62762.7 - } - } - ] + { + "test_name": "test_qwen_image_edit_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0672, - "latency_mean": 14.8517, - "peak_memory_mb_max": 53931.5, - "peak_memory_mb_mean": 53931.5 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.1129, + "latency_mean": 8.8352, + "peak_memory_mb_max": 55356.5, + "peak_memory_mb_mean": 55356.5 + } + }, + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0240, + "latency_mean": 41.5686, + "peak_memory_mb_max": 62762.7, + "peak_memory_mb_mean": 62762.7 + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + VAE patch parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_edit_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG=2 + CacheDiT", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Edit", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0672, + "latency_mean": 14.8517, + "peak_memory_mb_max": 53931.5, + "peak_memory_mb_mean": 53931.5 + } + } + ] + }, + { + "test_name": "test_qwen_image_edit_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG=2 + CacheDiT", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Edit", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.2879, - "latency_mean": 3.4701, - "peak_memory_mb_max": 55525.6, - "peak_memory_mb_mean": 55525.6 - } - }, - { - "name": "1536x1536_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1113, - "latency_mean": 8.9656, - "peak_memory_mb_max": 63758.3, - "peak_memory_mb_mean": 63758.3 - } - } - ] - } + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.2879, + "latency_mean": 3.4701, + "peak_memory_mb_max": 55525.6, + "peak_memory_mb_mean": 55525.6 + } + }, + { + "name": "1536x1536_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.1113, + "latency_mean": 8.9656, + "peak_memory_mb_max": 63758.3, + "peak_memory_mb_mean": 63758.3 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json index a94db56f5..3445dd94c 100644 --- a/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_layered_vllm_omni.json @@ -1,49 +1,61 @@ [ - { - "test_name": "test_qwen_image_layered_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image-Layered", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "640x640_steps20_i2i", - "dataset": "random", - "task": "i2i", - "width": 640, - "height": 640, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0703, - "latency_mean": 14.208, - "peak_memory_mb_max": 60543.5, - "peak_memory_mb_mean": 60543.5 - } - }, - { - "name": "1024x1024_steps35_i2i", - "dataset": "random", - "task": "i2i", - "width": 1024, - "height": 1024, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0417, - "latency_mean": 23.914, - "peak_memory_mb_max": 60543.5, - "peak_memory_mb_mean": 60543.5 - } - } - ] - } + { + "test_name": "test_qwen_image_layered_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image-Layered", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "640x640_steps20_i2i", + "dataset": "random", + "task": "i2i", + "width": 640, + "height": 640, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0703, + "latency_mean": 14.208, + "peak_memory_mb_max": 60543.5, + "peak_memory_mb_mean": 60543.5 + } + }, + { + "name": "1024x1024_steps35_i2i", + "dataset": "random", + "task": "i2i", + "width": 1024, + "height": 1024, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0417, + "latency_mean": 23.914, + "peak_memory_mb_max": 60543.5, + "peak_memory_mb_mean": 60543.5 + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json index 5ca6ed61a..b3daf7587 100644 --- a/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json +++ b/tests/dfx/perf/tests/test_qwen_image_vllm_omni.json @@ -1,219 +1,279 @@ [ - { - "test_name": "test_qwen_image_single_device", - "description": "Single-device baseline (no parallelism)", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.4153, - "latency_mean": 2.4065, - "peak_memory_mb_mean": 52289.9 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0423, - "latency_mean": 23.5991, - "peak_memory_mb_mean": 61360.5 - } - } - ] + { + "test_name": "test_qwen_image_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (no parallelism)", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_single_device_step_execution", - "description": "Single-device baseline (no parallelism) with step execution", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true, - "step-execution": true - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.4175, - "latency_mean": 2.3953, - "peak_memory_mb_mean": 56000 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.0423, - "latency_mean": 23.5768, - "peak_memory_mb_mean": 65000 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.4153, + "latency_mean": 2.4065, + "peak_memory_mb_mean": 52289.9 + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0423, + "latency_mean": 23.5991, + "peak_memory_mb_mean": 61360.5 + } + } + ] + }, + { + "test_name": "test_qwen_image_single_device_step_execution", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline (no parallelism) with step execution", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true, + "step-execution": true + } }, - { - "test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4", - "description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "vae-patch-parallel-size": 4, - "vae-use-tiling": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1214, - "latency_mean": 8.2211, - "peak_memory_mb_mean": 51704.7 - } - } - ] + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.4175, + "latency_mean": 2.3953, + "peak_memory_mb_mean": 56000 + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.0423, + "latency_mean": 23.5768, + "peak_memory_mb_mean": 65000 + } + } + ] + }, + { + "test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "vae-patch-parallel-size": 4, + "vae-use-tiling": true, + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_ulysses2_cfg2_cache_dit", - "description": "Ulysses SP=2 + CFG-parallel=2 + CacheDiT acceleration", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "ulysses-degree": 2, - "cfg-parallel-size": 2, - "cache-backend": "cache_dit", - "cache-config": { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": false, - "taylorseer_order": 1, - "scm_steps_mask_policy": null, - "scm_steps_policy": "dynamic" - }, - "enable-diffusion-pipeline-profiler": true - } + "benchmark_params": [ + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.1214, + "latency_mean": 8.2211, + "peak_memory_mb_mean": 51704.7 + } + } + ] + }, + { + "test_name": "test_qwen_image_ulysses2_cfg2_cache_dit", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 4 + } + }, + "full_model", + "diffusion" + ], + "description": "Ulysses SP=2 + CFG-parallel=2 + CacheDiT acceleration", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "ulysses-degree": 2, + "cfg-parallel-size": 2, + "cache-backend": "cache_dit", + "cache-config": { + "Fn_compute_blocks": 1, + "Bn_compute_blocks": 0, + "max_warmup_steps": 4, + "residual_diff_threshold": 0.24, + "max_continuous_cached_steps": 3, + "enable_taylorseer": false, + "taylorseer_order": 1, + "scm_steps_mask_policy": null, + "scm_steps_policy": "dynamic" }, - "benchmark_params": [ - { - "name": "512x512_steps20", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.559, - "latency_mean": 1.794, - "peak_memory_mb_mean": 52397.6775 - } - }, - { - "name": "1536x1536_steps35", - "dataset": "random", - "task": "t2i", - "width": 1536, - "height": 1536, - "num-inference-steps": 35, - "num-prompts": 10, - "max-concurrency": 1, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": 0.1918, - "latency_mean": 5.2022, - "peak_memory_mb_mean": 61468.8 - } - } - ] + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_qwen_image_single_device_step_execution_high_concurrency", - "description": "Single-device Qwen-Image step-level execution benchmark at 512x512, 20 steps, concurrency sweep", - "server_type": "vllm-omni", - "server_params": { - "model": "Qwen/Qwen-Image", - "serve_args": { - "enable-diffusion-pipeline-profiler": true, - "step-execution": true, - "max-num-seqs": 8 - } - }, - "benchmark_params": [ - { - "name": "512x512_steps20_high_concurrency", - "dataset": "random", - "task": "t2i", - "width": 512, - "height": 512, - "num-inference-steps": 20, - "num-prompts": [20, 40, 80, 160], - "max-concurrency": [1, 2, 4, 8], - "warmup-requests": 8, - "warmup-concurrency": 8, - "warmup-num-inference-steps": 20, - "enable-negative-prompt": true, - "baseline": { - "throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159], - "latency_mean": [2.4215, 2.992, 5.9293, 9.656], - "peak_memory_mb_mean": [67000, 67000, 67000, 67000] - } - } - ] - } + "benchmark_params": [ + { + "name": "512x512_steps20", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.559, + "latency_mean": 1.794, + "peak_memory_mb_mean": 52397.6775 + } + }, + { + "name": "1536x1536_steps35", + "dataset": "random", + "task": "t2i", + "width": 1536, + "height": 1536, + "num-inference-steps": 35, + "num-prompts": 10, + "max-concurrency": 1, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": 0.1918, + "latency_mean": 5.2022, + "peak_memory_mb_mean": 61468.8 + } + } + ] + }, + { + "test_name": "test_qwen_image_single_device_step_execution_high_concurrency", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device Qwen-Image step-level execution benchmark at 512x512, 20 steps, concurrency sweep", + "server_type": "vllm-omni", + "server_params": { + "model": "Qwen/Qwen-Image", + "serve_args": { + "enable-diffusion-pipeline-profiler": true, + "step-execution": true, + "max-num-seqs": 8 + } + }, + "benchmark_params": [ + { + "name": "512x512_steps20_high_concurrency", + "dataset": "random", + "task": "t2i", + "width": 512, + "height": 512, + "num-inference-steps": 20, + "num-prompts": [20, 40, 80, 160], + "max-concurrency": [1, 2, 4, 8], + "warmup-requests": 8, + "warmup-concurrency": 8, + "warmup-num-inference-steps": 20, + "enable-negative-prompt": true, + "baseline": { + "throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159], + "latency_mean": [2.4215, 2.992, 5.9293, 9.656], + "peak_memory_mb_mean": [67000, 67000, 67000, 67000] + } + } + ] + } ] diff --git a/tests/dfx/perf/tests/test_runner_metadata.py b/tests/dfx/perf/tests/test_runner_metadata.py index a195c4546..71d4aa95b 100644 --- a/tests/dfx/perf/tests/test_runner_metadata.py +++ b/tests/dfx/perf/tests/test_runner_metadata.py @@ -86,3 +86,202 @@ def test_enabled_false_entry_is_skipped(): # Only the enabled=True entry should appear assert len(params) == 1 assert params[0].get("task") == "voice_clone" + + +def test_resolve_pytest_marks_hardware_dict_with_extra(): + from tests.dfx.conftest import resolve_pytest_marks + + marks = resolve_pytest_marks( + [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 2}}, + "full_model", + "diffusion", + "local_model", + ] + ) + names = {m.name for m in marks} + assert "H100" in names + assert "cuda" in names + assert "gpu" in names + assert "distributed_cuda" in names + assert "full_model" in names + assert "diffusion" in names + assert "local_model" in names + + +def test_resolve_pytest_marks_rejects_legacy_object_format(): + from tests.dfx.conftest import resolve_pytest_marks + + with pytest.raises(ValueError, match="mark must be a list"): + resolve_pytest_marks( + { + "hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}, + "marks": ["full_model"], + } + ) + + +def test_extract_mark_resource_label(): + from tests.dfx.conftest import extract_mark_resource_label + + assert extract_mark_resource_label(None) == "na" + assert ( + extract_mark_resource_label( + [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "full_model", + ] + ) + == "H100" + ) + assert ( + extract_mark_resource_label( + [ + { + "hardware_marks": { + "res": {"cuda": "H100", "rocm": "MI325"}, + "num_cards": 2, + } + }, + "diffusion", + ] + ) + == "H100-MI325" + ) + + +def test_resource_label_for_filename(): + from tests.dfx.conftest import resource_label_for_filename + + assert resource_label_for_filename("H100") == "" + assert resource_label_for_filename("L4") == "L4" + assert resource_label_for_filename("910B") == "910B" + assert resource_label_for_filename("na") == "na" + + +def test_hardware_json_value(): + from tests.dfx.conftest import hardware_json_value + + assert hardware_json_value("H100") == "H100" + assert hardware_json_value("na") == "" + assert hardware_json_value(None) == "" + + +def test_extract_configs_resource_label(monkeypatch): + from tests.dfx.conftest import extract_configs_resource_label, get_runtime_resource_label + + monkeypatch.setattr( + "tests.dfx.conftest._read_runtime_device_name", + lambda *, device_id=0: "NVIDIA H100 80GB HBM3", + ) + get_runtime_resource_label(refresh=True) + assert extract_configs_resource_label([]) == "H100" + get_runtime_resource_label(refresh=True) + monkeypatch.setattr( + "tests.dfx.conftest._read_runtime_device_name", + lambda *, device_id=0: "Ascend910B2", + ) + assert get_runtime_resource_label(refresh=True) == "910B" + + +def test_load_benchmark_configs_from_dir(tmp_path): + from tests.dfx.conftest import load_benchmark_configs + + (tmp_path / "a.json").write_text( + json.dumps([{"test_name": "test_a", "server_params": {"model": "m/a"}, "benchmark_params": []}]), + encoding="utf-8", + ) + (tmp_path / "b.json").write_text( + json.dumps([{"test_name": "test_b", "server_params": {"model": "m/b"}, "benchmark_params": []}]), + encoding="utf-8", + ) + configs = load_benchmark_configs(config_dir=tmp_path) + assert [c["test_name"] for c in configs] == ["test_a", "test_b"] + + +def test_create_unique_server_pytest_params_applies_marks(tmp_path): + from tests.dfx.conftest import create_unique_server_pytest_params + + configs = [ + { + "test_name": "test_with_mark", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "full_model", + ], + "server_params": {"model": "some/model"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + { + "test_name": "test_without_mark", + "server_params": {"model": "other/model"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + ] + params = create_unique_server_pytest_params(configs, tmp_path) + by_id = {p.id: p for p in params} + assert len(by_id["test_with_mark"].values) == 1 + assert isinstance(by_id["test_with_mark"].values[0], tuple) + assert any(m.name == "H100" for m in by_id["test_with_mark"].marks) + assert not any(m.name == "H100" for m in by_id["test_without_mark"].marks) + + +def test_is_diffusion_perf_config(): + from tests.dfx.conftest import is_diffusion_perf_config + + assert not is_diffusion_perf_config( + {"test_name": "omni_a", "mark": [{"hardware_marks": {"res": {"cuda": "H100"}}}, "omni"]} + ) + assert is_diffusion_perf_config( + { + "test_name": "diff_a", + "server_type": "vllm-omni", + "mark": [{"hardware_marks": {"res": {"cuda": "H100"}}}, "diffusion"], + } + ) + + +def test_benchmark_param_id_suffix_from_task_eval_phase(): + from tests.dfx.conftest import _unique_benchmark_param_id_suffixes + + params = [ + {"task": "default_voice", "eval_phase": "latency"}, + {"task": "default_voice", "eval_phase": "throughput"}, + ] + assert _unique_benchmark_param_id_suffixes(params) == [ + "default_voice_latency", + "default_voice_throughput", + ] + + +def test_create_paired_omni_benchmark_pytest_params(tmp_path): + from tests.dfx.conftest import create_paired_omni_benchmark_pytest_params + + configs = [ + { + "test_name": "test_omni", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "omni", + ], + "server_params": {"model": "m/omni"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}, {"name": "p1", "num_prompts": 2}], + }, + { + "test_name": "test_tts", + "mark": [ + {"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, + "tts", + ], + "server_params": {"model": "m/tts"}, + "benchmark_params": [{"name": "p0", "num_prompts": 1}], + }, + ] + params = create_paired_omni_benchmark_pytest_params(configs, tmp_path) + by_id = {p.id: p for p in params} + assert set(by_id) == {"test_omni-p0", "test_omni-p1", "test_tts-p0"} + omni_row, bench_row = by_id["test_tts-p0"].values + assert omni_row[0] == "test_tts" + assert bench_row == ("test_tts", 0) + assert any(m.name == "tts" for m in by_id["test_tts-p0"].marks) + assert not any(m.name == "tts" for m in by_id["test_omni-p0"].marks) diff --git a/tests/dfx/perf/tests/test_tts.json b/tests/dfx/perf/tests/test_tts.json index 1ba273a2c..3bbf60935 100644 --- a/tests/dfx/perf/tests/test_tts.json +++ b/tests/dfx/perf/tests/test_tts.json @@ -1,6 +1,18 @@ [ { "test_name": "test_qwen3_tts_base", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", "stage_overrides": { @@ -98,6 +110,18 @@ }, { "test_name": "test_qwen3_tts_customvoice", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", "stage_overrides": { @@ -342,6 +366,18 @@ }, { "test_name": "test_voxtral_tts", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "mistralai/Voxtral-4B-TTS-2603" }, diff --git a/tests/dfx/perf/tests/test_voxcpm2.json b/tests/dfx/perf/tests/test_voxcpm2.json index 5c99f2596..ef72b5d88 100644 --- a/tests/dfx/perf/tests/test_voxcpm2.json +++ b/tests/dfx/perf/tests/test_voxcpm2.json @@ -1,6 +1,18 @@ [ { "test_name": "test_voxcpm2", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "tts" + ], "server_params": { "model": "openbmb/VoxCPM2", "trust_remote_code": true, diff --git a/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json b/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json index 07873a64b..78f720a6a 100644 --- a/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json +++ b/tests/dfx/perf/tests/test_wan22_i2v_vllm_omni.json @@ -1,107 +1,131 @@ [ - { - "test_name": "test_wan22_i2v_single_device", - "description": "Single-device baseline", - "server_type": "vllm-omni", - "server_params": { - "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "serve_args": { - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "832x480_frames81_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 832, - "height": 480, - "num_inference_steps": 4, - "num_frames": 81, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0465, - "latency_mean": 21.4354, - "peak_memory_mb_mean": 72557.58 - } - } - ] + { + "test_name": "test_wan22_i2v_single_device", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 1 + } + }, + "full_model", + "diffusion" + ], + "description": "Single-device baseline", + "server_type": "vllm-omni", + "server_params": { + "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", + "serve_args": { + "enable-diffusion-pipeline-profiler": true + } }, - { - "test_name": "test_wan22_i2v_usp2_vae_patch2_hsdp_slicing", - "description": "USP=2 + VAE patch parallel=2 + HSDP + VAE slicing", - "server_type": "vllm-omni", - "server_params": { - "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", - "serve_args": { - "usp": 2, - "vae-patch-parallel-size": 2, - "use-hsdp": true, - "vae-use-slicing": true, - "enable-diffusion-pipeline-profiler": true - } - }, - "benchmark_params": [ - { - "name": "832x480_frames81_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 832, - "height": 480, - "num_inference_steps": 4, - "num_frames": 81, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0642, - "latency_mean": 15.5589, - "peak_memory_mb_mean": 45742.88 - } - }, - { - "name": "1280x720_frames121_steps4", - "dataset": "random", - "task": "i2v", - "num-prompts": 10, - "max-concurrency": 1, - "num-input-images": 1, - "seed": 42, - "enable-negative-prompt": true, - "random-request-config": [ - { - "width": 1280, - "height": 720, - "num_inference_steps": 4, - "num_frames": 121, - "fps": 16, - "weight": 1 - } - ], - "baseline": { - "throughput_qps": 0.0128, - "latency_mean": 77.8565, - "peak_memory_mb_mean": 53947.84 - } - } - ] - } + "benchmark_params": [ + { + "name": "832x480_frames81_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 832, + "height": 480, + "num_inference_steps": 4, + "num_frames": 81, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "throughput_qps": 0.0465, + "latency_mean": 21.4354, + "peak_memory_mb_mean": 72557.58 + } + } + ] + }, + { + "test_name": "test_wan22_i2v_usp2_vae_patch2_hsdp_slicing", + "mark": [ + { + "hardware_marks": { + "res": { + "cuda": "H100" + }, + "num_cards": 2 + } + }, + "full_model", + "diffusion" + ], + "description": "USP=2 + VAE patch parallel=2 + HSDP + VAE slicing", + "server_type": "vllm-omni", + "server_params": { + "model": "Wan-AI/Wan2.2-I2V-A14B-Diffusers", + "serve_args": { + "usp": 2, + "vae-patch-parallel-size": 2, + "use-hsdp": true, + "vae-use-slicing": true, + "enable-diffusion-pipeline-profiler": true + } + }, + "benchmark_params": [ + { + "name": "832x480_frames81_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 832, + "height": 480, + "num_inference_steps": 4, + "num_frames": 81, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "throughput_qps": 0.0642, + "latency_mean": 15.5589, + "peak_memory_mb_mean": 45742.88 + } + }, + { + "name": "1280x720_frames121_steps4", + "dataset": "random", + "task": "i2v", + "num-prompts": 10, + "max-concurrency": 1, + "num-input-images": 1, + "seed": 42, + "enable-negative-prompt": true, + "random-request-config": [ + { + "width": 1280, + "height": 720, + "num_inference_steps": 4, + "num_frames": 121, + "fps": 16, + "weight": 1 + } + ], + "baseline": { + "throughput_qps": 0.0128, + "latency_mean": 77.8565, + "peak_memory_mb_mean": 53947.84 + } + } + ] + } ] From 0a9a5c231e39f6523ae404fcd17033c2d6134408 Mon Sep 17 00:00:00 2001 From: zzh <67720593+BLANKETusers@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:49:13 +0800 Subject: [PATCH 095/101] Fix hunyuan ci (#5143) Signed-off-by: zzh <943967662@qq.com> --- tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py | 1 + vllm_omni/config/omni_config.py | 1 + vllm_omni/diffusion/data.py | 3 +++ vllm_omni/diffusion/worker/diffusion_worker.py | 6 ++++++ 4 files changed, 11 insertions(+) diff --git a/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py b/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py index 6ac930f46..3f799c038 100644 --- a/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py +++ b/tests/e2e/accuracy/test_hunyuan_image3_pixel_accuracy.py @@ -48,6 +48,7 @@ _DEPLOY_CONFIG = { "trust_remote_code": True, "devices": "0,1,2,3", # set dynamically by _write_deploy_config "vae_use_slicing": False, + "moe_backend": "flashinfer_cutlass", "vae_use_tiling": False, "parallel_config": { "pipeline_parallel_size": 1, diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index dc5f4ef9c..256dcd5c7 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -469,6 +469,7 @@ class _DiffusionConfigProjection: diffusion_kv_cache_skip_layers: str | list[int] | tuple[int, ...] | set[int] | None = None diffusion_kv_cache_skip_step_indices: set[int] | None = None diffusion_kv_cache_skip_layer_indices: set[int] | None = None + moe_backend: str = "auto" force_cutlass_fp8: bool = False enable_diffusion_pipeline_profiler: bool = False step_execution: bool = False diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 0feab5b28..e131b833b 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -664,6 +664,9 @@ class OmniDiffusionConfig: # STA_mode: STA_Mode = STA_Mode.STA_INFERENCE skip_time_steps: int = 15 + # MoE kernel backend selection + moe_backend: str = "auto" + # Compilation enforce_eager: bool = False diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index 8cce47897..006bbae48 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -288,6 +288,12 @@ class DiffusionWorker: # Since vLLM v0.20.0, IR wraps GPU ops. Set IR op priority preference to enforce GPU op fusion during wrapping. # Also need to log, because vLLM internally logs another line in VllmConfig.__post_init__. Avoid confusion. vllm_config.kernel_config.ir_op_priority = _resolve_ir_op_priority(self.od_config, vllm_config) + if self.od_config.moe_backend != "auto": + logger.warning( + "Overriding MoE backend from default 'auto' to '%s' per deploy config.", + self.od_config.moe_backend, + ) + vllm_config.kernel_config.moe_backend = self.od_config.moe_backend logger.info( "Final IR op priority after setting vLLM-Omni overrides: %s", vllm_config.kernel_config.ir_op_priority ) From 85f7a5b374cbed6953df96b61dd8db1ff7e86429 Mon Sep 17 00:00:00 2001 From: bastefaniak <bstefaniak@nvidia.com> Date: Thu, 16 Jul 2026 18:39:40 +0200 Subject: [PATCH 096/101] Add Cosmos3 Edge and Distilled checkpoints support (#5001) Signed-off-by: Bartosz Stefaniak <bstefaniak@nvidia.com> --- .../models/cosmos3/test_cosmos3_pipeline.py | 809 ++++++++++++++++-- .../cosmos3/test_cosmos3_transformer.py | 309 ++++++- tests/e2e/accuracy/test_cosmos3_similarity.py | 2 - tests/model_extras/test_model_extras.py | 16 +- tests/model_tests/diffusion/test_alignment.py | 1 + .../diffusion/cache/cache_dit_backend.py | 1 + .../diffusion/models/cosmos3/__init__.py | 2 + .../models/cosmos3/pipeline_cosmos3.py | 397 +++++++-- .../models/cosmos3/transformer_cosmos3.py | 59 +- .../cosmos3/transformer_cosmos3_edge.py | 396 +++++++++ vllm_omni/diffusion/registry.py | 8 + vllm_omni/model_extras/registry.py | 5 + 12 files changed, 1789 insertions(+), 216 deletions(-) create mode 100644 vllm_omni/diffusion/models/cosmos3/transformer_cosmos3_edge.py diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index ba482e793..1085e3878 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -36,20 +36,36 @@ class StubScheduler: timesteps: list[int] | None = None, *, flow_shift: float = 1.0, - use_karras_sigmas: bool = True, ) -> None: self.timesteps = torch.tensor(timesteps or [9, 3], dtype=torch.int64) self.config = SimpleNamespace( num_train_timesteps=1000, flow_shift=flow_shift, - use_karras_sigmas=use_karras_sigmas, ) - self.set_timesteps_calls: list[tuple[int, torch.device]] = [] + self.set_timesteps_calls: list[dict[str, Any]] = [] self.step_calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = [] - def set_timesteps(self, num_steps: int, device: torch.device) -> None: - self.set_timesteps_calls.append((num_steps, device)) - self.timesteps = torch.arange(num_steps, 0, -1, dtype=torch.int64, device=device) + def set_timesteps( + self, + num_inference_steps: int | None = None, + device: str | torch.device | None = None, + *, + shift: float | None = None, + sigmas: list[float] | None = None, + ) -> None: + self.set_timesteps_calls.append( + { + "num_inference_steps": num_inference_steps, + "device": device, + "shift": shift, + "sigmas": sigmas, + } + ) + if sigmas is not None: + self.timesteps = torch.tensor(sigmas, device=device) + else: + assert num_inference_steps is not None + self.timesteps = torch.arange(num_inference_steps, 0, -1, dtype=torch.int64, device=device) def step(self, noise_pred: torch.Tensor, timestep: torch.Tensor, latents: torch.Tensor, **kwargs): del kwargs @@ -200,6 +216,7 @@ def fake_cosmos3_guardrails(monkeypatch: pytest.MonkeyPatch): def make_cosmos3_pipeline(): def _make(): from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT, Cosmos3OmniDiffusersPipeline, ) @@ -213,11 +230,10 @@ def make_cosmos3_pipeline(): pipeline.vae_scale_factor_temporal = 4 pipeline.vae_scale_factor_spatial = 8 pipeline.scheduler = StubScheduler([9, 3], flow_shift=1.0) - pipeline._base_scheduler_config = pipeline.scheduler.config - pipeline._engine_init_flow_shift = 1.0 - pipeline._current_flow_shift = 1.0 - pipeline._base_scheduler_use_karras_sigmas = True - pipeline._current_scheduler_use_karras_sigmas = True + pipeline._engine_init_flow_shift = COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT + pipeline._current_flow_shift = COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT + pipeline.is_distilled_model = False + pipeline.is_edge_model = False pipeline._guidance_scale = None pipeline._num_timesteps = None pipeline._cosmos3_branch_caches = None @@ -243,6 +259,7 @@ def make_sampling_params(**overrides: Any) -> SimpleNamespace: "num_frames": None, "num_inference_steps": None, "guidance_scale": None, + "guidance_scale_provided": False, "generator": None, "seed": 123, "num_outputs_per_prompt": 1, @@ -288,6 +305,165 @@ def _mask() -> torch.Tensor: return torch.ones(1, 1, dtype=torch.long) +def _capture_tokenize_calls(pipeline: Any) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + + def _tokenize( + text: str, + max_sequence_length: int, + use_system_prompt: bool = False, + system_prompt: str | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + index = len(calls) + 1 + calls.append( + { + "text": text, + "max_sequence_length": max_sequence_length, + "use_system_prompt": use_system_prompt, + "system_prompt": system_prompt, + } + ) + return _ids(index), torch.full((1, 1), index, dtype=torch.long) + + pipeline._tokenize_prompt = _tokenize + return calls + + +@pytest.mark.parametrize( + ("provided", "value", "default", "is_distilled", "expected"), + [ + (False, 1.0, 7.0, False, 7.0), + (True, 1.0, 7.0, False, 1.0), + (True, 4.5, 7.0, False, 4.5), + (False, 1.0, 7.0, True, 1.0), + (True, 4.5, 7.0, True, 1.0), + ], +) +def test_resolve_guidance_scale( + make_cosmos3_pipeline, + provided: bool, + value: float, + default: float, + is_distilled: bool, + expected: float, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = is_distilled + sp = make_sampling_params( + guidance_scale=value, + guidance_scale_provided=provided, + ) + + assert pipeline._resolve_guidance_scale(sp, default) == expected + + +def test_distilled_generation_accepts_t2i_and_i2v(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + common = { + "action_enabled": False, + "transfer_config": None, + "is_v2v": False, + "sound_enabled": False, + } + + pipeline._validate_distilled_generation_mode( + is_t2i=True, + image_tensor=None, + **common, + ) + pipeline._validate_distilled_generation_mode( + is_t2i=False, + image_tensor=torch.zeros(1), + **common, + ) + + +@pytest.mark.parametrize( + ("overrides", "message"), + [ + ({"action_enabled": True}, "action requests are unsupported"), + ({"transfer_config": SimpleNamespace()}, "transfer requests are unsupported"), + ({"is_v2v": True}, "video-to-video requests are unsupported"), + ({"sound_enabled": True}, "sound generation is unsupported"), + ( + {"is_t2i": False, "image_tensor": None}, + "text-to-video requests are unsupported", + ), + ( + {"is_t2i": True, "image_tensor": torch.zeros(1)}, + "image-conditioned image generation is unsupported", + ), + ], +) +def test_distilled_generation_rejects_unsupported_modes( + make_cosmos3_pipeline, + overrides: dict[str, Any], + message: str, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + kwargs = { + "is_t2i": True, + "image_tensor": None, + "action_enabled": False, + "transfer_config": None, + "is_v2v": False, + "sound_enabled": False, + } + kwargs.update(overrides) + + with pytest.raises(ValueError, match=message): + pipeline._validate_distilled_generation_mode(**kwargs) + + +def test_distilled_generation_rejects_robolab_policy(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + + with pytest.raises(ValueError, match="RoboLab/action policy requests are unsupported"): + pipeline._forward_robolab_policy(make_sampling_params(), None, 0.0) + + +@pytest.mark.parametrize( + ("prompt", "sampling_params", "message"), + [ + ( + {"prompt": "x", "modalities": ["video"], "generate_sound": True}, + make_sampling_params(), + "do not support sound generation", + ), + ( + {"prompt": "x", "modalities": ["video"]}, + make_sampling_params(extra_args={"edge": {"control_path": "/tmp/control.mp4"}}), + "do not support transfer inference", + ), + ( + { + "prompt": "x", + "modalities": ["video"], + "additional_information": { + "preprocessed_video": torch.zeros(1, 3, 5, 16, 16), + }, + }, + make_sampling_params(height=16, width=16, num_frames=5), + "do not support video-to-video generation", + ), + ], +) +def test_edge_forward_rejects_unsupported_modes( + make_cosmos3_pipeline, + prompt: dict[str, Any], + sampling_params: SimpleNamespace, + message: str, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_edge_model = True + + with pytest.raises(ValueError, match=message): + pipeline.forward(SimpleNamespace(prompts=[prompt], sampling_params=sampling_params)) + + def test_pipeline_registered_and_exported() -> None: from vllm_omni.diffusion.cache.cache_dit_backend import CUSTOM_DIT_ENABLERS from vllm_omni.diffusion.models import cosmos3 @@ -303,17 +479,45 @@ def test_pipeline_registered_and_exported() -> None: assert issubclass(Cosmos3OmniDiffusersPipeline, nn.Module) assert issubclass(Cosmos3OmniDiffusersPipeline, ProgressBarMixin) assert Cosmos3OmniDiffusersPipeline.support_image_input is True - assert _DIFFUSION_MODELS["Cosmos3OmniDiffusersPipeline"] == ( - "cosmos3", - "pipeline_cosmos3", - "Cosmos3OmniDiffusersPipeline", - ) - assert _DIFFUSION_PRE_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_pre_process_func" - assert _DIFFUSION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_post_process_func" - assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_ir_op_priority_func" - assert "Cosmos3OmniDiffusersPipeline" in CUSTOM_DIT_ENABLERS assert "Cosmos3OmniDiffusersPipeline" in cosmos3.__all__ + for pipeline_name in ("Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"): + assert _DIFFUSION_MODELS[pipeline_name] == ( + "cosmos3", + "pipeline_cosmos3", + "Cosmos3OmniDiffusersPipeline", + ) + assert _DIFFUSION_PRE_PROCESS_FUNCS[pipeline_name] == "get_cosmos3_pre_process_func" + assert _DIFFUSION_POST_PROCESS_FUNCS[pipeline_name] == "get_cosmos3_post_process_func" + assert _DIFFUSION_IR_OP_PRIORITY_FUNCS[pipeline_name] == "get_cosmos3_ir_op_priority_func" + assert pipeline_name in CUSTOM_DIT_ENABLERS + + +@pytest.mark.parametrize( + "pipeline_name", + ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"], +) +def test_cosmos3_model_index_resolves_pipeline( + tmp_path, + pipeline_name: str, +) -> None: + import json + + from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + ) + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + (tmp_path / "model_index.json").write_text(json.dumps({"_class_name": pipeline_name})) + + config = OmniDiffusionConfig(model=str(tmp_path)) + config.enrich_config() + + assert config.model_class_name == pipeline_name + resolved_pipeline_cls = DiffusionModelRegistry._try_load_model_cls(config.model_class_name) + assert resolved_pipeline_cls is Cosmos3OmniDiffusersPipeline + @pytest.fixture def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): @@ -335,25 +539,24 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): return self class _StubDiffusersScheduler: + load_config_calls: list[dict[str, Any]] = [] from_config_calls: list[dict[str, Any]] = [] - def __init__(self, *, flow_shift: float = 1.0, use_karras_sigmas: bool = True) -> None: - self.config = SimpleNamespace(flow_shift=flow_shift, use_karras_sigmas=use_karras_sigmas) + def __init__(self, *, flow_shift: float = 1.0) -> None: + self.config = SimpleNamespace(flow_shift=flow_shift) @classmethod - def from_pretrained(cls, *args, **kwargs): - return cls() + def load_config(cls, *args, **kwargs): + cls.load_config_calls.append({"args": args, "kwargs": dict(kwargs)}) + return { + "_class_name": "UniPCMultistepScheduler", + "flow_shift": 1.0, + } @classmethod def from_config(cls, config, **kwargs): cls.from_config_calls.append({"config": config, "kwargs": dict(kwargs)}) - return cls( - flow_shift=kwargs.get("flow_shift", getattr(config, "flow_shift", 1.0)), - use_karras_sigmas=kwargs.get( - "use_karras_sigmas", - getattr(config, "use_karras_sigmas", True), - ), - ) + return cls(flow_shift=kwargs.get("shift", config.get("flow_shift", 1.0))) class _StubVideoProcessor: def __init__(self, *args, **kwargs) -> None: @@ -361,13 +564,18 @@ def stub_real_pipeline_init(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(pipeline_cosmos3, "AutoTokenizer", _StubAutoTokenizer) monkeypatch.setattr(pipeline_cosmos3, "DistributedAutoencoderKLWan", _StubDiffusersVAE) - monkeypatch.setattr(pipeline_cosmos3, "UniPCMultistepScheduler", _StubDiffusersScheduler) + monkeypatch.setattr(pipeline_cosmos3, "FlowUniPCMultistepScheduler", _StubDiffusersScheduler) monkeypatch.setattr(pipeline_cosmos3, "VideoProcessor", _StubVideoProcessor) monkeypatch.setattr(pipeline_cosmos3, "get_local_device", lambda: torch.device("cpu")) return _StubDiffusersScheduler -def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: +def _make_od_config( + *, + sound_gen: bool, + tf_model_config_overrides: dict[str, Any] | None = None, + model_config: dict[str, Any] | None = None, +) -> SimpleNamespace: tf_model_config = { "hidden_size": 8, "num_hidden_layers": 0, @@ -382,6 +590,8 @@ def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: } if sound_gen: tf_model_config["sound_gen"] = True + if tf_model_config_overrides: + tf_model_config.update(tf_model_config_overrides) return SimpleNamespace( enable_cpu_offload=False, enable_diffusion_pipeline_profiler=False, @@ -390,7 +600,7 @@ def _make_od_config(*, sound_gen: bool) -> SimpleNamespace: flow_shift=None, quantization_config=None, custom_pipeline_args={}, - model_config={}, + model_config=model_config or {}, tf_model_config=tf_model_config, ) @@ -400,16 +610,13 @@ def test_pipeline_init_skips_tokenizer_when_sound_disabled(stub_real_pipeline_in pipeline = Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) - assert stub_real_pipeline_init.from_config_calls == [] assert pipeline._sound_tokenizer is None assert pipeline.transformer.sound_gen is False assert not hasattr(pipeline.transformer, "audio_proj_in") assert not hasattr(pipeline.transformer, "audio_proj_out") - assert pipeline._base_scheduler_use_karras_sigmas is True - assert pipeline._current_scheduler_use_karras_sigmas is True -def test_pipeline_init_rebuilds_scheduler_with_cosmos3_defaults(stub_real_pipeline_init) -> None: +def test_pipeline_init_uses_flow_unipc_with_cosmos3_defaults(stub_real_pipeline_init) -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline od_config = _make_od_config(sound_gen=False) @@ -417,55 +624,302 @@ def test_pipeline_init_rebuilds_scheduler_with_cosmos3_defaults(stub_real_pipeli pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config) - assert stub_real_pipeline_init.from_config_calls - call = stub_real_pipeline_init.from_config_calls[-1] - assert getattr(call["config"], "flow_shift") == 1.0 - assert call["kwargs"] == {"flow_shift": 2.5} + assert len(stub_real_pipeline_init.load_config_calls) == 1 + assert len(stub_real_pipeline_init.from_config_calls) == 1 + call = stub_real_pipeline_init.from_config_calls[0] + assert call["config"]["_class_name"] == "UniPCMultistepScheduler" + assert call["kwargs"] == { + "shift": 1.0, + "use_dynamic_shifting": False, + "prediction_type": "flow_prediction", + } + assert pipeline.is_distilled_model is False assert pipeline._engine_init_flow_shift == 2.5 - assert pipeline._base_scheduler_use_karras_sigmas is True -def test_set_flow_shift_restores_checkpoint_scheduler_mode( - make_cosmos3_pipeline, +@pytest.mark.parametrize( + ("scheduler_class_name", "expected_distilled"), + [ + ("FlowMatchEulerDiscreteScheduler", True), + ("UniPCMultistepScheduler", False), + ], +) +def test_pipeline_resolves_scheduler_class_from_checkpoint_file( + tmp_path, + stub_real_pipeline_init, monkeypatch: pytest.MonkeyPatch, + scheduler_class_name: str, + expected_distilled: bool, ) -> None: + import json + from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3 + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline + from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( + FlowUniPCMultistepScheduler as RealFlowUniPCMultistepScheduler, + ) - class RecordingScheduler: - from_config_calls: list[dict[str, Any]] = [] + t_list = [1.0, 0.75, 0.5, 0.25] + scheduler_dir = tmp_path / "scheduler" + scheduler_dir.mkdir() + scheduler_config = {"_class_name": scheduler_class_name} + if expected_distilled: + scheduler_config["fixed_step_sampler_config"] = {"sample_type": "sde", "t_list": t_list} + (scheduler_dir / "scheduler_config.json").write_text(json.dumps(scheduler_config)) - def __init__(self, *, flow_shift: float, use_karras_sigmas: bool) -> None: - self.config = SimpleNamespace(flow_shift=flow_shift, use_karras_sigmas=use_karras_sigmas) + class StubFlowUniPCScheduler(StubScheduler): + from_config_calls: list[tuple[Any, dict[str, Any]]] = [] + + @classmethod + def load_config(cls, *args, **kwargs): + return RealFlowUniPCMultistepScheduler.load_config(*args, **kwargs) @classmethod def from_config(cls, config, **kwargs): - cls.from_config_calls.append({"config": config, "kwargs": dict(kwargs)}) - return cls( - flow_shift=kwargs.get("flow_shift", getattr(config, "flow_shift", 1.0)), - use_karras_sigmas=kwargs.get( - "use_karras_sigmas", - getattr(config, "use_karras_sigmas", True), - ), - ) + cls.from_config_calls.append((config, dict(kwargs))) + return cls() - monkeypatch.setattr(pipeline_cosmos3, "UniPCMultistepScheduler", RecordingScheduler) - pipeline = make_cosmos3_pipeline() + class StubFlowMatchScheduler(StubScheduler): + from_config_calls: list[tuple[Any, dict[str, Any]]] = [] - pipeline._set_flow_shift(10.0, use_karras_sigmas=False) - assert RecordingScheduler.from_config_calls[-1]["kwargs"] == { - "flow_shift": 10.0, - "use_karras_sigmas": False, + @classmethod + def from_config(cls, config, **kwargs): + cls.from_config_calls.append((config, dict(kwargs))) + scheduler = cls() + scheduler.config.fixed_step_sampler_config = config["fixed_step_sampler_config"] + return scheduler + + monkeypatch.setattr(pipeline_cosmos3, "FlowUniPCMultistepScheduler", StubFlowUniPCScheduler) + monkeypatch.setattr( + pipeline_cosmos3, + "FlowMatchEulerDiscreteScheduler", + StubFlowMatchScheduler, + ) + + od_config = _make_od_config(sound_gen=False) + od_config.model = str(tmp_path) + pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config) + + assert pipeline.is_distilled_model is expected_distilled + if expected_distilled: + assert len(StubFlowMatchScheduler.from_config_calls) == 1 + assert StubFlowMatchScheduler.from_config_calls[0][1] == {"stochastic_sampling": True} + assert StubFlowUniPCScheduler.from_config_calls == [] + assert pipeline._scheduler_init_t_list == t_list + else: + assert len(StubFlowUniPCScheduler.from_config_calls) == 1 + assert StubFlowMatchScheduler.from_config_calls == [] + assert not hasattr(pipeline, "_scheduler_init_t_list") + + +def test_distilled_pipeline_initializes_sde_scheduler( + stub_real_pipeline_init, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from diffusers import FlowMatchEulerDiscreteScheduler + + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + ) + + t_list = [1.0, 0.9375, 0.8333333333333334, 0.625] + scheduler_config = { + "_class_name": "FlowMatchEulerDiscreteScheduler", + "shift": 1.0, + "stochastic_sampling": False, + "fixed_step_sampler_config": { + "sample_type": "sde", + "t_list": t_list, + }, } - assert pipeline._current_flow_shift == 10.0 - assert pipeline._current_scheduler_use_karras_sigmas is False + monkeypatch.setattr( + stub_real_pipeline_init, + "load_config", + classmethod(lambda cls, *args, **kwargs: scheduler_config), + ) - pipeline._set_flow_shift(10.0) - assert RecordingScheduler.from_config_calls[-1]["kwargs"] == {"flow_shift": 10.0} - assert pipeline._current_flow_shift == 10.0 - assert pipeline._current_scheduler_use_karras_sigmas is True + pipeline = Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) + assert pipeline.is_distilled_model is True + assert isinstance(pipeline.scheduler, FlowMatchEulerDiscreteScheduler) + assert pipeline.scheduler.config.stochastic_sampling is True + assert pipeline._scheduler_init_t_list == t_list - pipeline._set_flow_shift(10.0) - assert len(RecordingScheduler.from_config_calls) == 2 + +@pytest.mark.parametrize( + ("fixed_step_config", "error_pattern"), + [ + pytest.param( + {"t_list": [1.0, 0.5]}, + r"fixed_step_sampler_config\.sample_type=sde", + id="missing-sample-type", + ), + pytest.param( + {"sample_type": "ode", "t_list": [1.0, 0.5]}, + r"fixed_step_sampler_config\.sample_type=sde", + id="unsupported-sample-type", + ), + pytest.param( + {"sample_type": "sde"}, + r"non-empty fixed_step_sampler_config\.t_list", + id="missing-t-list", + ), + pytest.param( + {"sample_type": "sde", "t_list": []}, + r"non-empty fixed_step_sampler_config\.t_list", + id="empty-t-list", + ), + ], +) +def test_distilled_scheduler_validates_fixed_step_config( + stub_real_pipeline_init, + monkeypatch: pytest.MonkeyPatch, + fixed_step_config: dict[str, Any], + error_pattern: str, +) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + Cosmos3OmniDiffusersPipeline, + ) + + scheduler_config = { + "_class_name": "FlowMatchEulerDiscreteScheduler", + "fixed_step_sampler_config": fixed_step_config, + } + monkeypatch.setattr( + stub_real_pipeline_init, + "load_config", + classmethod(lambda cls, *args, **kwargs: scheduler_config), + ) + + with pytest.raises(ValueError, match=error_pattern): + Cosmos3OmniDiffusersPipeline(od_config=_make_od_config(sound_gen=False)) + + +def test_pipeline_init_selects_edge_transformer_from_backbone_type(stub_real_pipeline_init) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import ( + COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT, + Cosmos3OmniDiffusersPipeline, + ) + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + COSMOS3_EDGE_BACKBONE_TYPE, + Cosmos3EdgeVFMTransformer, + ) + + pipeline = Cosmos3OmniDiffusersPipeline( + od_config=_make_od_config( + sound_gen=False, + tf_model_config_overrides={ + "backbone_type": COSMOS3_EDGE_BACKBONE_TYPE, + "qk_norm_for_text": False, + "latent_channel": 48, + "latent_patch_size": 2, + "temporal_compression_factor": 4, + "layer_norm_epsilon": 1e-5, + }, + ) + ) + + assert isinstance(pipeline.transformer, Cosmos3EdgeVFMTransformer) + assert pipeline.is_edge_model is True + assert pipeline._engine_init_flow_shift == COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT + + +def test_pipeline_init_does_not_select_edge_from_model_index_class_name(stub_real_pipeline_init) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import Cosmos3EdgeVFMTransformer + + pipeline = Cosmos3OmniDiffusersPipeline( + od_config=_make_od_config( + sound_gen=False, + model_config={"_class_name": "Cosmos3EdgeOmniDiffusersPipeline"}, + ) + ) + + assert isinstance(pipeline.transformer, Cosmos3VFMTransformer) + assert not isinstance(pipeline.transformer, Cosmos3EdgeVFMTransformer) + + +def test_flow_unipc_reuses_scheduler_and_forwards_each_request_shift(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + scheduler = pipeline.scheduler + + assert pipeline._engine_init_flow_shift == 10.0 + assert pipeline._current_flow_shift == 10.0 + assert scheduler.config.flow_shift == 1.0 + + for shift in (3.0, 10.0): + pipeline._set_flow_shift(shift) + pipeline._set_timesteps(4, torch.device("cpu"), shift=pipeline._current_flow_shift) + + assert pipeline.scheduler is scheduler + assert pipeline._current_flow_shift == 10.0 + assert [call["shift"] for call in scheduler.set_timesteps_calls] == [3.0, 10.0] + assert all(call["num_inference_steps"] == 4 for call in scheduler.set_timesteps_calls) + assert all(call["sigmas"] is None for call in scheduler.set_timesteps_calls) + + +def test_flow_unipc_reproducible_with_same_seed(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( + FlowUniPCMultistepScheduler, + ) + + pipeline = make_cosmos3_pipeline() + pipeline.scheduler = FlowUniPCMultistepScheduler( + shift=1.0, + use_dynamic_shifting=False, + prediction_type="flow_prediction", + ) + pipeline._format_and_tokenize_prompts = lambda *args, **kwargs: ( + _ids(1), + _mask(), + _ids(0), + _mask(), + ) + + def run(seed: int) -> torch.Tensor: + request = make_request_batch( + {"prompt": "A test video.", "modalities": ["video"]}, + make_sampling_params( + seed=seed, + guidance_scale=1.0, + guidance_scale_provided=True, + num_inference_steps=4, + num_frames=5, + height=16, + width=16, + extra_args={"flow_shift": 3.0}, + ), + ) + output = pipeline.forward(request) + return output.output["video"] + + first = run(123) + second = run(123) + different_seed = run(456) + + torch.testing.assert_close(first, second) + assert not torch.equal(first, different_seed) + + +def test_distilled_set_timesteps_uses_fixed_sigma_schedule(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.is_distilled_model = True + pipeline._scheduler_init_t_list = [1.0, 0.75, 0.5, 0.25] + + pipeline._set_timesteps( + num_inference_steps=99, + device=torch.device("cpu"), + shift=7.0, + ) + + assert pipeline.scheduler.set_timesteps_calls == [ + { + "num_inference_steps": None, + "device": torch.device("cpu"), + "shift": None, + "sigmas": pipeline._scheduler_init_t_list, + } + ] def test_pipeline_init_passes_tokenizer_attrs_into_transformer( @@ -806,12 +1260,35 @@ def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.Monke assert merged.custom_op == ["platform_kernel", "native"] -def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> None: - from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline +def test_format_and_tokenize_prompts_leaves_plain_prompts_unchanged_by_default(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import COSMOS3_SYSTEM_PROMPT pipeline = make_cosmos3_pipeline() - captured: list[str] = [] - pipeline._tokenize_prompt = lambda text, *args, **kwargs: (captured.append(text) or _ids(len(captured)), _mask()) + calls = _capture_tokenize_calls(pipeline) + + result = pipeline._format_and_tokenize_prompts( + " A robot. ", + " bad. ", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace(extra_args={}), + use_system_prompt=False, + is_t2i=False, + ) + + assert [call["text"] for call in calls] == ["A robot.", "bad."] + assert all(call["max_sequence_length"] == 32 for call in calls) + assert all(call["use_system_prompt"] is False for call in calls) + assert all(call["system_prompt"] == COSMOS3_SYSTEM_PROMPT for call in calls) + assert [tensor.item() for tensor in result] == [1, 1, 2, 2] + + +def test_format_and_tokenize_prompts_applies_video_templates_and_system_override(make_cosmos3_pipeline) -> None: + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) pipeline._format_and_tokenize_prompts( "A robot", @@ -821,21 +1298,167 @@ def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> No height=720, width=1280, max_sequence_length=32, + sp=SimpleNamespace( + system_prompt="direct system prompt", + extra_args={ + "use_duration_template": True, + "use_resolution_template": True, + "system_prompt": "API system prompt", + }, + ), + use_system_prompt=True, + is_t2i=False, + ) + + assert calls == [ + { + "text": ("A robot. The video is 2.0 seconds long and is of 24 FPS. This video is of 720x1280 resolution."), + "max_sequence_length": 32, + "use_system_prompt": True, + "system_prompt": "API system prompt", + }, + { + "text": ( + "bad. The video is not 2.0 seconds long and is not of 24 FPS. This video is not of 720x1280 resolution." + ), + "max_sequence_length": 32, + "use_system_prompt": True, + "system_prompt": "API system prompt", + }, + ] + + +def test_format_and_tokenize_prompts_uses_image_templates_for_t2i(make_cosmos3_pipeline) -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import COSMOS3_T2I_SYSTEM_PROMPT + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + "A robot", + "bad", + num_frames=1, + frame_rate=24, + height=1024, + width=768, + max_sequence_length=64, sp=SimpleNamespace( extra_args={ - "negative_metadata_mode": "inverse", - # Duration/resolution metadata templates are off by default - # (commit "Change resolution and duration templates to off by - # default"); enable them explicitly to exercise the formatting. "use_duration_template": True, "use_resolution_template": True, } ), use_system_prompt=True, + is_t2i=True, + ) + + assert [call["text"] for call in calls] == [ + "A robot. This image is of 1024x768 resolution.", + "bad. This image is not of 1024x768 resolution.", + ] + assert all("seconds" not in call["text"] and "FPS" not in call["text"] for call in calls) + assert all(call["system_prompt"] == COSMOS3_T2I_SYSTEM_PROMPT for call in calls) + + +def test_format_and_tokenize_prompts_rewrites_json_object_metadata(make_cosmos3_pipeline) -> None: + import json + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + ('{"caption": "A robot", "duration": "old", "fps": 1, "resolution": {"H": 1, "W": 2}, "aspect_ratio": "1:1"}'), + "bad", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace( + extra_args={ + "aspect_ratio": "16:9", + "use_duration_template": True, + "use_resolution_template": True, + } + ), is_t2i=False, ) - assert "The video is 2.0 seconds long" in captured[0] - assert "The video is not 2.0 seconds long" in captured[1] + + assert json.loads(calls[0]["text"]) == { + "caption": "A robot", + "duration": "2s", + "fps": 24.0, + "resolution": {"H": 720, "W": 1280}, + "aspect_ratio": "16:9", + } + assert "The video is 2.0 seconds long" not in calls[0]["text"] + assert calls[1]["text"] == ( + "bad. The video is not 2.0 seconds long and is not of 24 FPS. This video is not of 720x1280 resolution." + ) + + +def test_format_and_tokenize_prompts_removes_video_metadata_from_t2i_json(make_cosmos3_pipeline) -> None: + import json + + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + '{"caption": "A robot", "duration": "2s", "fps": 24}', + "", + num_frames=1, + frame_rate=24, + height=1024, + width=768, + max_sequence_length=32, + sp=SimpleNamespace(extra_args={}), + is_t2i=True, + ) + + assert json.loads(calls[0]["text"]) == { + "caption": "A robot", + "resolution": {"H": 1024, "W": 768}, + } + + +@pytest.mark.parametrize( + "prompt", + [ + "{malformed", + '["A robot"]', + ], +) +def test_format_and_tokenize_prompts_falls_back_for_non_object_json( + make_cosmos3_pipeline, + prompt: str, +) -> None: + pipeline = make_cosmos3_pipeline() + calls = _capture_tokenize_calls(pipeline) + + pipeline._format_and_tokenize_prompts( + prompt, + "", + num_frames=48, + frame_rate=24, + height=720, + width=1280, + max_sequence_length=32, + sp=SimpleNamespace( + extra_args={ + "use_duration_template": True, + "use_resolution_template": True, + } + ), + is_t2i=False, + ) + + assert calls[0]["text"] == ( + f"{prompt}. The video is 2.0 seconds long and is of 24 FPS. This video is of 720x1280 resolution." + ) + + +def test_checkpoint_key_remap() -> None: + from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline remaps = { "embed_tokens.weight": "transformer.language_model.embed_tokens.weight", @@ -847,9 +1470,14 @@ def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> No "layers.3.self_attn.to_q.weight": "transformer.language_model.layers.3.self_attn.to_q.weight", "layers.3.self_attn.to_out.weight": "transformer.language_model.layers.3.self_attn.to_out.weight", "layers.3.self_attn.norm_q.weight": "transformer.language_model.layers.3.self_attn.norm_q.weight", + "layers.3.self_attn.k_norm_und_for_gen.weight": ( + "transformer.language_model.layers.3.self_attn.k_norm_und_for_gen.weight" + ), "layers.3.self_attn.add_q_proj.weight": "transformer.gen_layers.3.cross_attention.to_q.weight", "layers.3.self_attn.to_add_out.weight": "transformer.gen_layers.3.cross_attention.to_out.weight", "layers.3.self_attn.norm_added_q.weight": "transformer.gen_layers.3.cross_attention.norm_q.weight", + "layers.3.mlp_moe_gen.up_proj.weight": "transformer.gen_layers.3.mlp.up_proj.weight", + "layers.3.mlp_moe_gen.down_proj.weight": "transformer.gen_layers.3.mlp.down_proj.weight", "transformer.model.layers.3.self_attn.add_k_proj.weight": ( "transformer.gen_layers.3.cross_attention.to_k.weight" ), @@ -1193,9 +1821,9 @@ def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint pipeline._prepare_transfer_latents = fake_prepare pipeline.diffuse_transfer = fake_diffuse_transfer - def fake_set_flow_shift(target, *, use_karras_sigmas=None): + def fake_set_flow_shift(target): captured.setdefault("flow_shifts", []).append(target) - captured.setdefault("scheduler_use_karras_sigmas", []).append(use_karras_sigmas) + pipeline._current_flow_shift = float(target) pipeline._set_flow_shift = fake_set_flow_shift pipeline._decode_latents = lambda latents: torch.zeros(1, 3, 5, 16, 16, device="meta") @@ -1230,8 +1858,8 @@ def test_forward_transfer_uses_source_fps_except_wsm(make_cosmos3_pipeline, hint assert captured["format_frame_rate"] == expected_fps assert captured["shared_kwargs"]["fps"] == expected_fps assert captured["flow_shifts"] == [10.0] - # Transfer uses the V2V flow-sigma schedule (karras off) so flow_shift applies. - assert captured["scheduler_use_karras_sigmas"] == [False] + # Transfer applies the V2V flow shift when building its timestep schedule. + assert [call["shift"] for call in pipeline.scheduler.set_timesteps_calls] == [10.0] assert output.output["metadata"]["video"]["fps"] == expected_fps assert output.output["payload"]["video"].device.type == "meta" @@ -1391,9 +2019,9 @@ class TestForwardRouting: pipeline._format_and_tokenize_prompts = fake_format pipeline._prepare_latents = fake_prepare - def fake_set_flow_shift(target, *, use_karras_sigmas=None): + def fake_set_flow_shift(target): captured.setdefault("flow_shifts", []).append(target) - captured.setdefault("scheduler_use_karras_sigmas", []).append(use_karras_sigmas) + pipeline._current_flow_shift = float(target) pipeline._set_flow_shift = fake_set_flow_shift pipeline.diffuse = fake_diffuse @@ -1420,7 +2048,7 @@ class TestForwardRouting: { "key": "video", "is_t2i": False, - "flow": [1.0], + "flow": [10.0], "steps": [35], "frames": 189, }, @@ -1443,8 +2071,8 @@ class TestForwardRouting: assert captured["format"]["is_t2i"] is expected["is_t2i"] assert captured["format"]["num_frames"] == expected["frames"] assert captured["flow_shifts"] == expected["flow"] - assert captured["scheduler_use_karras_sigmas"] == [None] - assert [call[0] for call in pipeline.scheduler.set_timesteps_calls] == expected["steps"] + assert [call["num_inference_steps"] for call in pipeline.scheduler.set_timesteps_calls] == expected["steps"] + assert all(call["shift"] == expected["flow"][0] for call in pipeline.scheduler.set_timesteps_calls) def test_forward_i2v_sound_and_action_routes(self, make_cosmos3_pipeline) -> None: pipeline = make_cosmos3_pipeline() @@ -1491,7 +2119,6 @@ class TestForwardRouting: ) ) assert captured["flow_shifts"][-1] == 10.0 - assert captured["scheduler_use_karras_sigmas"][-1] is False 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 diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py index 13d1c399b..cb59dffe5 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py @@ -15,6 +15,25 @@ from vllm_omni.platforms import current_omni_platform pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] +@pytest.fixture(autouse=True) +def _single_rank_tensor_parallel(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide the TP metadata required by vLLM parallel linear layers.""" + from vllm.model_executor import parameter + from vllm.model_executor.layers import linear + + from vllm_omni.diffusion.models.cosmos3 import ( + transformer_cosmos3, + transformer_cosmos3_edge, + ) + + monkeypatch.setattr(linear, "get_tensor_model_parallel_rank", lambda: 0) + monkeypatch.setattr(linear, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(parameter, "get_tensor_model_parallel_rank", lambda: 0) + monkeypatch.setattr(parameter, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(transformer_cosmos3, "get_tensor_model_parallel_world_size", lambda: 1) + monkeypatch.setattr(transformer_cosmos3_edge, "get_tensor_model_parallel_world_size", lambda: 1) + + @pytest.fixture def accelerator_device() -> torch.device: """Provide an accelerator device, skipping when none is available.""" @@ -40,6 +59,22 @@ def _tiny_cosmos3_config(**overrides): return config +def _tiny_cosmos3_edge_config(**overrides): + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + COSMOS3_EDGE_BACKBONE_TYPE, + ) + + config = _tiny_cosmos3_config( + latent_patch_size=2, + latent_channel=48, + temporal_compression_factor=4, + backbone_type=COSMOS3_EDGE_BACKBONE_TYPE, + qk_norm_for_text=False, + ) + config.update(overrides) + return config + + def test_mrope_position_ids_cover_text_video_sound_and_action() -> None: from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import ( compute_mrope_position_ids_action, @@ -78,6 +113,16 @@ def test_mrope_position_ids_cover_text_video_sound_and_action() -> None: assert action_offset == 14 +def test_timestep_embedder_stores_frequencies_in_fp32() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import ( + TimestepEmbedder, + ) + + embedder = TimestepEmbedder(hidden_size=8, frequency_embedding_size=16) + + assert embedder.freqs.dtype == torch.float32 + + @pytest.mark.parametrize( ("key", "value"), [ @@ -97,30 +142,245 @@ def test_validate_supported_config_rejects_unsupported_flags(key: str, value) -> Cosmos3VFMTransformer._validate_supported_config(None) +def test_edge_config_resolves_nemotron_defaults() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + layer_norm_epsilon=1e-5, + rope_scaling={}, + rope_parameters={"mrope_section": [24, 20, 20]}, + ), + dtype=torch.float32, + ) + ) + + assert model.rms_norm_eps == 1e-5 + assert model.rope_theta == 100_000_000 + assert model.mrope_section == [24, 20, 20] + assert model.latent_channel_size == 48 + assert model.latent_patch_size == 2 + assert model.temporal_compression_factor == 4 + + +def test_edge_config_requires_backbone_type() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + config = _tiny_cosmos3_edge_config() + config.pop("backbone_type") + + with pytest.raises(ValueError, match="must declare backbone_type"): + Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=config, + dtype=torch.float32, + ) + ) + + +def test_edge_und_and_gen_use_relu2_and_no_und_qk_norm() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + Cosmos3Relu2MLP, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + use_k_norm_und_for_gen=False, + ), + dtype=torch.float32, + ) + ) + + layer = model.language_model.layers[0] + gen_layer = model.gen_layers[0] + assert isinstance(layer.mlp, Cosmos3Relu2MLP) + assert not hasattr(layer.mlp, "gate_proj") + assert isinstance(gen_layer.mlp, Cosmos3Relu2MLP) + assert not hasattr(gen_layer.mlp, "gate_proj") + assert not hasattr(layer.self_attn, "norm_q") + assert not hasattr(layer.self_attn, "norm_k") + assert layer.self_attn.k_norm_und_for_gen is None + + +def test_edge_creates_gen_k_norm_when_enabled() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import RMSNorm + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + qk_norm_for_diffusion=True, + use_k_norm_und_for_gen=True, + ), + dtype=torch.float32, + ) + ) + + k_norm = model.language_model.layers[0].self_attn.k_norm_und_for_gen + assert model.use_k_norm_und_for_gen is True + assert isinstance(k_norm, RMSNorm) + assert isinstance(model.gen_layers[0].cross_attention.norm_q, RMSNorm) + assert isinstance(model.gen_layers[0].cross_attention.norm_k, RMSNorm) + + +def test_edge_supports_no_gen_qk_norm_variant() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = Cosmos3EdgeVFMTransformer( + SimpleNamespace( + tf_model_config=_tiny_cosmos3_edge_config( + num_hidden_layers=1, + qk_norm_for_diffusion=False, + use_k_norm_und_for_gen=False, + ), + dtype=torch.float32, + ) + ) + + assert model.qk_norm_for_diffusion is False + assert model.language_model.layers[0].self_attn.k_norm_und_for_gen is None + assert model.gen_layers[0].cross_attention.qk_norm is False + assert not hasattr(model.gen_layers[0].cross_attention, "norm_q") + assert not hasattr(model.gen_layers[0].cross_attention, "norm_k") + + +def test_edge_validates_required_relu2_weights() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeVFMTransformer, + ) + + model = object.__new__(Cosmos3EdgeVFMTransformer) + nn.Module.__init__(model) + model.num_hidden_layers = 1 + model.qk_norm_for_diffusion = True + model.use_k_norm_und_for_gen = True + + complete = { + "transformer.language_model.layers.0.mlp.up_proj.weight", + "transformer.language_model.layers.0.mlp.down_proj.weight", + "transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight", + "transformer.gen_layers.0.mlp.up_proj.weight", + "transformer.gen_layers.0.mlp.down_proj.weight", + } + model.validate_loaded_weights(complete) + + mlp_weights = complete - { + "transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight", + } + for missing_weight in mlp_weights: + with pytest.raises(ValueError, match="missing required weights"): + model.validate_loaded_weights(complete - {missing_weight}) + + missing_k_norm = complete - {"transformer.language_model.layers.0.self_attn.k_norm_und_for_gen.weight"} + with pytest.raises(ValueError, match=r"self_attn\.k_norm_und_for_gen"): + model.validate_loaded_weights(missing_k_norm) + + model.use_k_norm_und_for_gen = False + model.validate_loaded_weights(missing_k_norm) + + +def test_edge_gen_cached_k_is_normalized_but_reasoner_uses_raw_k() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import RMSNorm + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeCausalAttention, + ) + + class ScaleProjection(nn.Module): + def __init__(self, scale: float) -> None: + super().__init__() + self.scale = scale + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x * self.scale + + class IdentityOutput(nn.Module): + def forward(self, x: torch.Tensor) -> torch.Tensor: + return x + + class RawKAttention(nn.Module): + def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor) -> torch.Tensor: + del v + return q + k + + attn = object.__new__(Cosmos3EdgeCausalAttention) + nn.Module.__init__(attn) + attn.hidden_size = 4 + attn.num_heads = 2 + attn.num_kv_heads = 2 + attn.head_dim = 2 + attn.num_heads_local = 2 + attn.num_kv_heads_local = 2 + attn.to_q = ScaleProjection(1.0) + attn.to_k = ScaleProjection(1.0) + attn.to_v = ScaleProjection(1.0) + attn.to_out = IdentityOutput() + attn.k_norm_und_for_gen = RMSNorm(2, eps=1e-5) + attn.attn = RawKAttention() + + hidden_states = torch.tensor( + [[[1.0, 2.0, 3.0, 4.0], [2.0, 1.0, 4.0, 3.0]]], + dtype=torch.float32, + ) + freqs_cos = torch.ones(1, 2, 1, 2) + freqs_sin = torch.zeros(1, 2, 1, 2) + + reasoner_out, gen_k, gen_v = attn(hidden_states, freqs_cos, freqs_sin) + attn.to_k.scale = 3.0 + scaled_reasoner_out, scaled_gen_k, scaled_gen_v = attn(hidden_states, freqs_cos, freqs_sin) + + torch.testing.assert_close(gen_k, scaled_gen_k) + torch.testing.assert_close(gen_v, scaled_gen_v) + assert not torch.allclose(reasoner_out, scaled_reasoner_out) + + def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None: from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3LanguageModel, Cosmos3VFMTransformer + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3_edge import ( + Cosmos3EdgeLanguageModel, + Cosmos3EdgeVFMTransformer, + ) - model = object.__new__(Cosmos3VFMTransformer) - nn.Module.__init__(model) - model.language_model = nn.Module() - model.language_model.layers = nn.ModuleList([nn.Linear(2, 2) for _ in range(2)]) - model.gen_layers = nn.ModuleList([nn.Linear(2, 2)]) - model.norm_moe_gen = nn.LayerNorm(2) + for transformer_cls, language_model_cls in ( + (Cosmos3VFMTransformer, Cosmos3LanguageModel), + (Cosmos3EdgeVFMTransformer, Cosmos3EdgeLanguageModel), + ): + model = object.__new__(transformer_cls) + nn.Module.__init__(model) + model.language_model = nn.Module() + model.language_model.layers = nn.ModuleList([nn.Linear(2, 2) for _ in range(2)]) + model.gen_layers = nn.ModuleList([nn.Linear(2, 2)]) + model.norm_moe_gen = nn.LayerNorm(2) - matched = [ - name - for name, module in model.named_modules() - if any(condition(name, module) for condition in model._hsdp_shard_conditions) - ] - assert matched == ["language_model.layers.0", "language_model.layers.1", "gen_layers.0"] - assert Cosmos3VFMTransformer._layerwise_offload_blocks_attrs == ["gen_layers"] - assert Cosmos3LanguageModel._layerwise_offload_blocks_attrs == ["layers"] - assert Cosmos3VFMTransformer._repeated_blocks == ["Cosmos3GenDecoderLayer"] + matched = [ + name + for name, module in model.named_modules() + if any(condition(name, module) for condition in model._hsdp_shard_conditions) + ] + assert matched == ["language_model.layers.0", "language_model.layers.1", "gen_layers.0"] + assert transformer_cls._layerwise_offload_blocks_attrs == ["gen_layers"] + assert language_model_cls._layerwise_offload_blocks_attrs == ["layers"] + assert transformer_cls._repeated_blocks == ["Cosmos3GenDecoderLayer"] - model.latent_patch_size = 2 - model.latent_channel_size = 3 - latents = torch.arange(1 * 3 * 1 * 3 * 5, dtype=torch.float32).reshape(1, 3, 1, 3, 5) - torch.testing.assert_close(model.unpatchify(model.patchify(latents, t=1, h=3, w=5), t=1, h=3, w=5), latents) + model.latent_patch_size = 2 + model.latent_channel_size = 3 + latents = torch.arange(1 * 3 * 1 * 3 * 5, dtype=torch.float32).reshape(1, 3, 1, 3, 5) + torch.testing.assert_close( + model.unpatchify(model.patchify(latents, t=1, h=3, w=5), t=1, h=3, w=5), + latents, + ) def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> None: @@ -142,6 +402,17 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No assert tuple(output.shape) == (1, 2, 1, 2, 2) +def test_qwen_gen_mlp_remains_gated() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GatedMLP, Cosmos3VFMTransformer + + model = Cosmos3VFMTransformer( + SimpleNamespace(tf_model_config=_tiny_cosmos3_config(num_hidden_layers=1), dtype=torch.float32) + ) + + assert isinstance(model.gen_layers[0].mlp, Cosmos3GatedMLP) + assert hasattr(model.gen_layers[0].mlp, "gate_proj") + + def test_model_cpu_offload_swaps_back_to_generator(monkeypatch: pytest.MonkeyPatch) -> None: from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 diff --git a/tests/e2e/accuracy/test_cosmos3_similarity.py b/tests/e2e/accuracy/test_cosmos3_similarity.py index ff2350096..efa095fb1 100644 --- a/tests/e2e/accuracy/test_cosmos3_similarity.py +++ b/tests/e2e/accuracy/test_cosmos3_similarity.py @@ -42,8 +42,6 @@ def _server_args() -> list[str]: return [ "--num-gpus", "1", - "--model-class-name", - "Cosmos3OmniDiffusersPipeline", "--stage-init-timeout", "900", "--init-timeout", diff --git a/tests/model_extras/test_model_extras.py b/tests/model_extras/test_model_extras.py index f2db9ded0..76ef84e7e 100644 --- a/tests/model_extras/test_model_extras.py +++ b/tests/model_extras/test_model_extras.py @@ -60,8 +60,9 @@ def test_sensenova_extra_registry_declares_request_and_response_params() -> None @pytest.mark.core_model @pytest.mark.cpu -def test_cosmos3_extra_registry_declares_request_and_response_params() -> None: - assert get_extra_body_params("Cosmos3OmniDiffusersPipeline") == frozenset( +@pytest.mark.parametrize("pipeline_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"]) +def test_cosmos3_extra_registry_declares_request_and_response_params(pipeline_name: str) -> None: + assert get_extra_body_params(pipeline_name) == frozenset( { "flow_shift", "max_sequence_length", @@ -98,7 +99,7 @@ def test_cosmos3_extra_registry_declares_request_and_response_params() -> None: "session_id", } ) - assert get_extra_output_params("Cosmos3OmniDiffusersPipeline") == frozenset( + assert get_extra_output_params(pipeline_name) == frozenset( { "action", "raw_action_dim", @@ -106,7 +107,7 @@ def test_cosmos3_extra_registry_declares_request_and_response_params() -> None: "action_mode", } ) - assert should_init_extra_args_for_non_diffusion_stages("Cosmos3OmniDiffusersPipeline") is False + assert should_init_extra_args_for_non_diffusion_stages(pipeline_name) is False @pytest.mark.core_model @@ -224,9 +225,10 @@ def test_ming_flash_omni_image_to_image_prompt_builder() -> None: @pytest.mark.core_model @pytest.mark.cpu -def test_cosmos3_text_to_image_prompt_builder_selects_image_modality() -> None: +@pytest.mark.parametrize("pipeline_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"]) +def test_cosmos3_text_to_image_prompt_builder_selects_image_modality(pipeline_name: str) -> None: assert build_text_to_image_prompt( - "Cosmos3OmniDiffusersPipeline", + pipeline_name, prompt="a red sports car at golden hour", negative_prompt="blurry, distorted", height=1024, @@ -237,7 +239,7 @@ def test_cosmos3_text_to_image_prompt_builder_selects_image_modality() -> None: "negative_prompt": "blurry, distorted", } assert build_text_to_image_prompt( - "Cosmos3OmniDiffusersPipeline", + pipeline_name, prompt="a red sports car", negative_prompt=None, ) == {"prompt": "a red sports car", "modalities": ["image"]} diff --git a/tests/model_tests/diffusion/test_alignment.py b/tests/model_tests/diffusion/test_alignment.py index ae01334f6..c95644143 100644 --- a/tests/model_tests/diffusion/test_alignment.py +++ b/tests/model_tests/diffusion/test_alignment.py @@ -71,6 +71,7 @@ EXCLUDED_MODELS = [ "OmniVoicePipeline", "OmniVoice", "Cosmos3OmniDiffusersPipeline", + "Cosmos3OmniPipeline", "DiffusersAdapterPipeline", "HiDreamImagePipeline", "DreamZeroPipeline", diff --git a/vllm_omni/diffusion/cache/cache_dit_backend.py b/vllm_omni/diffusion/cache/cache_dit_backend.py index 48e64de0a..5d63ce3c0 100644 --- a/vllm_omni/diffusion/cache/cache_dit_backend.py +++ b/vllm_omni/diffusion/cache/cache_dit_backend.py @@ -1016,6 +1016,7 @@ CUSTOM_DIT_ENABLERS.update( "Wan22VACEPipeline": enable_cache_for_wan22, "Wan22S2VPipeline": enable_cache_for_wan22_s2v, "Cosmos3OmniDiffusersPipeline": enable_cache_for_cosmos3, + "Cosmos3OmniPipeline": enable_cache_for_cosmos3, "Krea2Pipeline": enable_cache_for_krea2, } ) diff --git a/vllm_omni/diffusion/models/cosmos3/__init__.py b/vllm_omni/diffusion/models/cosmos3/__init__.py index 6df062b5c..af4eeaacf 100644 --- a/vllm_omni/diffusion/models/cosmos3/__init__.py +++ b/vllm_omni/diffusion/models/cosmos3/__init__.py @@ -7,10 +7,12 @@ from .pipeline_cosmos3 import ( get_cosmos3_pre_process_func, ) from .transformer_cosmos3 import Cosmos3VFMTransformer +from .transformer_cosmos3_edge import Cosmos3EdgeVFMTransformer __all__ = [ "Cosmos3OmniDiffusersPipeline", "get_cosmos3_post_process_func", "get_cosmos3_pre_process_func", "Cosmos3VFMTransformer", + "Cosmos3EdgeVFMTransformer", ] diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 5d0e24789..58e74ccf4 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -29,6 +29,7 @@ is produced from sound latents rather than from ``multi_modal_data["audio"]``. from __future__ import annotations +import json import math import os import time @@ -39,7 +40,7 @@ from typing import Any, ClassVar import numpy as np import PIL.Image import torch -from diffusers import UniPCMultistepScheduler +from diffusers import FlowMatchEulerDiscreteScheduler from diffusers.utils.torch_utils import randn_tensor from diffusers.video_processor import VideoProcessor from torch import nn @@ -61,6 +62,9 @@ from vllm_omni.diffusion.models.interface import ( SupportsComponentDiscovery, ) from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero +from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( + FlowUniPCMultistepScheduler, +) from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch @@ -94,7 +98,8 @@ from .transfer import ( transfer_max_frames_from_extra_args, uint8_cthw_to_normalized_5d, ) -from .transformer_cosmos3 import Cosmos3VFMTransformer, resolve_sound_gen +from .transformer_cosmos3 import Cosmos3VFMTransformer, _tf_config_get, resolve_sound_gen +from .transformer_cosmos3_edge import COSMOS3_EDGE_BACKBONE_TYPE, Cosmos3EdgeVFMTransformer from .utils import ( COSMOS3_DEFAULT_CONDITION_FRAME_INDEXES_VISION, COSMOS3_VAE_TEMPORAL_COMPRESSION, @@ -154,6 +159,7 @@ COSMOS3_T2V_DEFAULT_WIDTH = 1280 COSMOS3_T2V_DEFAULT_NUM_FRAMES = 189 COSMOS3_T2V_DEFAULT_NUM_INFERENCE_STEPS = 35 COSMOS3_T2V_DEFAULT_GUIDANCE_SCALE = 6.0 +COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT = 10.0 COSMOS3_T2I_DEFAULT_HEIGHT = 1024 COSMOS3_T2I_DEFAULT_WIDTH = 1024 @@ -162,12 +168,73 @@ COSMOS3_T2I_DEFAULT_GUIDANCE_SCALE = 7.0 COSMOS3_T2I_DEFAULT_FLOW_SHIFT = 3.0 COSMOS3_T2I_DEFAULT_GUIDANCE_INTERVAL: tuple[float, float] = (400.0, 1000.0) +COSMOS3_EDGE_T2V_DEFAULT_HEIGHT = 480 +COSMOS3_EDGE_T2V_DEFAULT_WIDTH = 832 +COSMOS3_EDGE_T2V_DEFAULT_GUIDANCE_SCALE = 5.0 +COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT = 3.0 +COSMOS3_EDGE_T2I_DEFAULT_HEIGHT = 640 +COSMOS3_EDGE_T2I_DEFAULT_WIDTH = 640 + +COSMOS3_DISTILLED_CHECKPOINT_SCHEDULER_CLASS = "FlowMatchEulerDiscreteScheduler" + # Truncation cap on the prompt token count (shared by T2I and T2V). Prompts # are tokenized to their natural length (no padding); this only bounds the # UND pathway / GEN cross-attention cost for pathologically long prompts. COSMOS3_DEFAULT_MAX_SEQUENCE_LENGTH = 4096 +def _ceil_video_num_frames(num_frames: int, temporal_compression_factor: int) -> int: + """Round a video length up to the causal VAE's ``factor * k + 1`` grid.""" + if temporal_compression_factor <= 0: + raise ValueError(f"Cosmos3 temporal_compression_factor must be positive, got {temporal_compression_factor}.") + if num_frames <= 1: + return num_frames + latent_frames = math.ceil((num_frames - 1) / temporal_compression_factor) + return latent_frames * temporal_compression_factor + 1 + + +def _format_json_object_prompt( + prompt: str, + *, + num_frames: int, + frame_rate: float, + height: int, + width: int, + aspect_ratio: str | None, +) -> str | None: + """Match Imaginaire4 metadata injection for JSON-object prompts.""" + try: + prompt_obj = json.loads(prompt) + except (json.JSONDecodeError, TypeError, ValueError): + return None + if not isinstance(prompt_obj, dict): + return None + + metadata: dict[str, Any] = {} + if num_frames > 1: + duration_seconds = int(num_frames / frame_rate) if frame_rate > 0 else 0 + metadata.update({"duration": f"{duration_seconds}s", "fps": float(frame_rate)}) + else: + prompt_obj.pop("duration", None) + prompt_obj.pop("fps", None) + metadata["resolution"] = {"H": int(height), "W": int(width)} + if aspect_ratio is not None: + metadata["aspect_ratio"] = aspect_ratio + + prompt_obj.update(metadata) + return json.dumps(prompt_obj) + + +def resolve_cosmos3_transformer_cls(model_config: Any) -> type[Cosmos3VFMTransformer]: + """Select the Cosmos3 transformer implementation from transformer/config.json.""" + backbone_type = _tf_config_get(model_config, "backbone_type", None) + if backbone_type is None: + return Cosmos3VFMTransformer + if backbone_type == COSMOS3_EDGE_BACKBONE_TYPE: + return Cosmos3EdgeVFMTransformer + raise ValueError(f"Unsupported Cosmos3 transformer backbone_type={backbone_type!r}.") + + # --------------------------------------------------------------------------- # Post-process function (registered in registry.py) # --------------------------------------------------------------------------- @@ -190,6 +257,9 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): from .guardrails import check_text_safety, ensure_initialized, is_guardrails_enabled video_processor = VideoProcessor(vae_scale_factor=16) + is_edge_model = ( + _tf_config_get(getattr(od_config, "tf_model_config", None), "backbone_type", None) == COSMOS3_EDGE_BACKBONE_TYPE + ) # Eager-load guardrail models at pipeline build time when the server-level # gate is on. Per-request overrides only decide whether the loaded models # are *invoked* — they cannot turn checks on without a server-side preload. @@ -410,12 +480,17 @@ def get_cosmos3_pre_process_func(od_config: OmniDiffusionConfig): extra = _extra_args(request) transfer_requested = action_mode is None and has_transfer_hints(extra) - # Auto-calculate H/W from aspect ratio (720p max area) + # Resolve missing H/W. if transfer_requested: _set_transfer_size_from_image(request, image) elif request.sampling_params.height is None or request.sampling_params.width is None: if action_mode is not None: _set_action_size_from_image(request, image) + elif is_edge_model: + if request.sampling_params.height is None: + request.sampling_params.height = COSMOS3_EDGE_T2V_DEFAULT_HEIGHT + if request.sampling_params.width is None: + request.sampling_params.width = COSMOS3_EDGE_T2V_DEFAULT_WIDTH else: max_area = 720 * 1280 aspect_ratio = image.height / image.width @@ -823,27 +898,52 @@ class Cosmos3OmniDiffusersPipeline( sound_latent_fps = self._sound_tokenizer.latent_fps # --- Transformer (weights loaded later via weights_sources) --- - self.transformer = Cosmos3VFMTransformer( + transformer_cls = resolve_cosmos3_transformer_cls(od_config.tf_model_config) + self.transformer = transformer_cls( od_config=od_config, temporal_compression_factor=self.vae_scale_factor_temporal, sound_gen=sound_gen, sound_dim=sound_dim, sound_latent_fps=sound_latent_fps, ) + self.is_edge_model = transformer_cls is Cosmos3EdgeVFMTransformer # --- Scheduler --- - # Load from checkpoint to preserve solver_order, timestep_spacing, - # beta_schedule, sigma bounds, flow_shift, etc. Only override - # flow_shift when explicitly requested by the user. - self.scheduler = UniPCMultistepScheduler.from_pretrained( + # Distilled model differs from regular one only by scheduler, + # distilled one uses FlowMatchEulerDiscreteScheduler, while + # regular should use FlowUniPCMultistepScheduler + + scheduler_config = FlowUniPCMultistepScheduler.load_config( model_path, subfolder="scheduler", local_files_only=local_files_only, ) - if od_config.flow_shift is not None: - self.scheduler = UniPCMultistepScheduler.from_config( - self.scheduler.config, - flow_shift=od_config.flow_shift, + + scheduler_class_name = scheduler_config.get("_class_name") + + self.is_distilled_model = False + if scheduler_class_name == COSMOS3_DISTILLED_CHECKPOINT_SCHEDULER_CLASS: + fixed_step_config = scheduler_config.get("fixed_step_sampler_config") + if not isinstance(fixed_step_config, dict) or fixed_step_config.get("sample_type") != "sde": + raise ValueError("Cosmos3 distilled scheduler requires fixed_step_sampler_config.sample_type=sde.") + t_list = fixed_step_config.get("t_list") + if not isinstance(t_list, list) or not t_list: + raise ValueError("Cosmos3 distilled scheduler requires a non-empty fixed_step_sampler_config.t_list.") + self.scheduler = FlowMatchEulerDiscreteScheduler.from_config( + scheduler_config, + stochastic_sampling=True, + ) + self._scheduler_init_t_list = list(t_list) + self.is_distilled_model = True + else: + # Preserve compatible solver settings from the checkpoint, but keep + # the base shift neutral. The concrete request shift is applied when + # FlowUniPC builds its timesteps. + self.scheduler = FlowUniPCMultistepScheduler.from_config( + scheduler_config, + shift=1.0, + use_dynamic_shifting=False, + prediction_type="flow_prediction", ) self._cpu_scheduler_state() @@ -862,14 +962,16 @@ class Cosmos3OmniDiffusersPipeline( ), ] - # Snapshot the loaded scheduler config so we can rebuild the - # scheduler at request time when a per-request flow_shift override - # is supplied (T2I uses shift=3.0; T2V/I2V use the engine default). - self._base_scheduler_config = self.scheduler.config - self._engine_init_flow_shift = float(getattr(self.scheduler.config, "flow_shift", 1.0) or 1.0) + # An engine-level override becomes the video default; otherwise use + # the matching regular/Edge default. Per-request values still take + # precedence. + default_video_flow_shift = ( + COSMOS3_EDGE_VIDEO_DEFAULT_FLOW_SHIFT if self.is_edge_model else COSMOS3_VIDEO_DEFAULT_FLOW_SHIFT + ) + self._engine_init_flow_shift = float( + od_config.flow_shift if od_config.flow_shift is not None else default_video_flow_shift + ) self._current_flow_shift = self._engine_init_flow_shift - self._base_scheduler_use_karras_sigmas = self._scheduler_use_karras_sigmas(self.scheduler.config) - self._current_scheduler_use_karras_sigmas = self._base_scheduler_use_karras_sigmas self._guidance_scale = None self._num_timesteps = None @@ -989,6 +1091,7 @@ class Cosmos3OmniDiffusersPipeline( "self_attn.to_out.": f"{und_lp}.self_attn.to_out.", "self_attn.norm_q.": f"{und_lp}.self_attn.norm_q.", "self_attn.norm_k.": f"{und_lp}.self_attn.norm_k.", + "self_attn.k_norm_und_for_gen.": f"{und_lp}.self_attn.k_norm_und_for_gen.", # GEN attention "self_attn.add_q_proj.": f"{gen_lp}.cross_attention.to_q.", "self_attn.add_k_proj.": f"{gen_lp}.cross_attention.to_k.", @@ -1054,6 +1157,7 @@ class Cosmos3OmniDiffusersPipeline( loaded = loader.load_weights(_remapped_weights()) self.transformer.post_load_weights() self.transformer.eval() + self.transformer.validate_loaded_weights(loaded) if getattr(self.transformer, "sound_gen", False): sound_markers = ("audio_proj_in.", "audio_proj_out.", "audio_modality_embed") missing = [marker.rstrip(".") for marker in sound_markers if not any(marker in name for name in loaded)] @@ -1359,6 +1463,8 @@ class Cosmos3OmniDiffusersPipeline( inputs: RoboLabPolicyInputs, pipeline_start: float, ) -> DiffusionOutput: + if self.is_distilled_model: + raise self._distilled_unsupported_error("RoboLab/action policy requests are unsupported.") if not getattr(self.transformer, "action_gen", False): raise ValueError( "Cosmos3 RoboLab policy serving was requested, but the transformer " @@ -1469,6 +1575,7 @@ class Cosmos3OmniDiffusersPipeline( guidance_interval=None, raw_action_dim=raw_action_dim, scheduler=scheduler, + generator=generator, ) if _is_rank_zero(): @@ -1560,38 +1667,34 @@ class Cosmos3OmniDiffusersPipeline( raise ValueError(f"Incorrect modality value in {modalities}, expected one of {accepted_modalities}.") return "image" in modalities + def _set_timesteps(self, num_inference_steps: int, device: str | torch.device, shift: float) -> None: + if self.is_distilled_model: + self.scheduler.set_timesteps(sigmas=self._scheduler_init_t_list, device=device) + else: + self.scheduler.set_timesteps( + num_inference_steps, + device=device, + shift=shift, + ) + + def _set_flow_shift(self, target_shift: float) -> None: + """Select the shift applied by FlowUniPC when timesteps are built.""" + self._current_flow_shift = float(target_shift) + @staticmethod - def _scheduler_use_karras_sigmas(config: Any) -> bool | None: - value = getattr(config, "use_karras_sigmas", None) - return None if value is None else bool(value) + def _resolve_seed(sp: OmniDiffusionSamplingParams, generator: torch.Generator | None, default: int = 42) -> int: + if sp.seed is not None: + return int(sp.seed) + if isinstance(generator, torch.Generator): + return int(generator.initial_seed()) + return int(default) - def _set_flow_shift(self, target_shift: float, *, use_karras_sigmas: bool | None = None) -> None: - """Set UniPC scheduler mode for a concrete request. - - The scheduler is rebuilt from the saved base config if - the target differs from the current shift or Karras-sigma mode. - Tracking explicit scheduler state is required because the previous - mode may have rebuilt the scheduler - we cannot rely on - ``self.scheduler.config`` reflecting the last requested target if a - rebuild was skipped via the equality check. - """ - target = float(target_shift) - target_use_karras_sigmas = ( - self._base_scheduler_use_karras_sigmas if use_karras_sigmas is None else bool(use_karras_sigmas) - ) - if ( - target == float(self._current_flow_shift) - and target_use_karras_sigmas == self._current_scheduler_use_karras_sigmas - ): - return - - scheduler_kwargs: dict[str, Any] = {"flow_shift": target} - if use_karras_sigmas is not None: - scheduler_kwargs["use_karras_sigmas"] = bool(use_karras_sigmas) - self.scheduler = UniPCMultistepScheduler.from_config(self._base_scheduler_config, **scheduler_kwargs) - self._cpu_scheduler_state() - self._current_flow_shift = target - self._current_scheduler_use_karras_sigmas = self._scheduler_use_karras_sigmas(self.scheduler.config) + def _resolve_guidance_scale(self, sp: OmniDiffusionSamplingParams, default: float) -> float: + if self.is_distilled_model: + return 1.0 + if sp.guidance_scale_provided: + return float(sp.guidance_scale) + return float(default) def _cpu_scheduler_state(self) -> None: # We need to move scheduler tensors to CPU, as unipc from diffusers assumes they are on CPU. @@ -1612,6 +1715,53 @@ class Cosmos3OmniDiffusersPipeline( def num_timesteps(self): return self._num_timesteps + @staticmethod + def _distilled_unsupported_error(detail: str) -> ValueError: + return ValueError( + f"Cosmos3 distilled checkpoints support only text-to-image and image-to-video generation; {detail}" + ) + + def _validate_distilled_generation_mode( + self, + *, + is_t2i: bool, + image_tensor: torch.Tensor | None, + action_enabled: bool, + transfer_config: Cosmos3TransferConfig | None, + is_v2v: bool, + sound_enabled: bool, + ) -> None: + if not self.is_distilled_model: + return + if action_enabled: + raise self._distilled_unsupported_error("action requests are unsupported.") + if transfer_config is not None: + raise self._distilled_unsupported_error("transfer requests are unsupported.") + if is_v2v: + raise self._distilled_unsupported_error("video-to-video requests are unsupported.") + if sound_enabled: + raise self._distilled_unsupported_error("sound generation is unsupported.") + if not is_t2i and image_tensor is None: + raise self._distilled_unsupported_error("text-to-video requests are unsupported.") + if is_t2i and image_tensor is not None: + raise self._distilled_unsupported_error("image-conditioned image generation is unsupported.") + + def _validate_edge_generation_mode( + self, + *, + transfer_config: Cosmos3TransferConfig | None, + is_v2v: bool, + sound_enabled: bool, + ) -> None: + if not self.is_edge_model: + return + if sound_enabled: + raise ValueError("Cosmos3 Edge checkpoints do not support sound generation.") + if transfer_config is not None: + raise ValueError("Cosmos3 Edge checkpoints do not support transfer inference.") + if is_v2v: + raise ValueError("Cosmos3 Edge checkpoints do not support video-to-video generation.") + # -- Prompt formatting ----------------------------------------------------- @staticmethod @@ -1628,6 +1778,10 @@ class Cosmos3OmniDiffusersPipeline( """ Append duration and resolution metadata to a prompt. """ + prompt = prompt.strip() + if duration_template is None and resolution_template is None: + return prompt + parts: list[str] = [] head = prompt.rstrip(".").strip() if head: @@ -1903,15 +2057,26 @@ class Cosmos3OmniDiffusersPipeline( res_tmpl = COSMOS3_IMAGE_RESOLUTION_TEMPLATE if is_t2i else COSMOS3_RESOLUTION_TEMPLATE else: res_tmpl = None - prompt = self._apply_metadata_templates( + json_prompt = _format_json_object_prompt( prompt, - num_frames, - frame_rate, - height, - width, - duration_template=dur_tmpl, - resolution_template=res_tmpl, + num_frames=num_frames, + frame_rate=frame_rate, + height=height, + width=width, + aspect_ratio=self._get_sp_param(sp, "aspect_ratio", None), ) + if json_prompt is not None: + prompt = json_prompt + else: + prompt = self._apply_metadata_templates( + prompt, + num_frames, + frame_rate, + height, + width, + duration_template=dur_tmpl, + resolution_template=res_tmpl, + ) if _is_rank_zero(): logger.info("Final prompt: '%s'", prompt) @@ -2272,6 +2437,7 @@ class Cosmos3OmniDiffusersPipeline( guidance_interval: tuple[float, float] | None = None, raw_action_dim: int | None = None, scheduler: Any | None = None, + generator: torch.Generator | None = None, ) -> torch.Tensor | tuple[torch.Tensor, ...]: """Denoising loop with 3-mode CFG support (parallel, sequential, none). @@ -2383,11 +2549,23 @@ class Cosmos3OmniDiffusersPipeline( if raw_action_dim is not None and 0 < raw_action_dim < action_pred.shape[-1]: action_pred[..., raw_action_dim:] = 0 if action_latents is None and sound_latents is None: - latents = step_scheduler.step(video_pred, t, latents, return_dict=False)[0] + latents = step_scheduler.step( + video_pred, + t, + latents, + generator=generator, + return_dict=False, + )[0] else: packed_noise, shapes, numels = _pack_joint(video_pred, action_pred, sound_pred) packed_latents, _, _ = _pack_joint(latents, action_latents, sound_latents) - packed_next = step_scheduler.step(packed_noise, t, packed_latents, return_dict=False)[0] + packed_next = step_scheduler.step( + packed_noise, + t, + packed_latents, + generator=generator, + return_dict=False, + )[0] unpacked = _unpack_joint(packed_next, shapes, numels) latents = unpacked[0] idx = 1 @@ -2613,6 +2791,7 @@ class Cosmos3OmniDiffusersPipeline( velocity_mask: torch.Tensor, condition_latents: torch.Tensor, guidance_interval: tuple[float, float] | None = None, + generator: torch.Generator | None = None, ) -> torch.Tensor: def _active_at(t: torch.Tensor, interval: tuple[float, float] | None) -> bool: if interval is None: @@ -2724,7 +2903,13 @@ class Cosmos3OmniDiffusersPipeline( if isinstance(noise_pred, tuple): raise ValueError("Cosmos3 transfer diffusion expects video-only tensor predictions.") noise_pred = noise_pred * velocity_mask - latents = self.scheduler.step(noise_pred, t, latents, return_dict=False)[0] + latents = self.scheduler.step( + noise_pred, + t, + latents, + generator=generator, + return_dict=False, + )[0] latents = velocity_mask * latents + (1.0 - velocity_mask) * condition_latents finally: self._cosmos3_branch_caches = None @@ -2741,6 +2926,8 @@ class Cosmos3OmniDiffusersPipeline( transfer_video_tensor: torch.Tensor | None, transfer_input_fps: float | None, ) -> DiffusionOutput: + if self.is_distilled_model: + raise self._distilled_unsupported_error("transfer requests are unsupported.") input_frames = None if transfer_video_tensor is not None: input_frames = normalized_video_to_uint8_cthw(transfer_video_tensor) @@ -2806,7 +2993,7 @@ class Cosmos3OmniDiffusersPipeline( guidance_scale = ( float(transfer_config.guidance_scale) if transfer_config.guidance_scale is not None - else float(sp.guidance_scale or COSMOS3_T2V_DEFAULT_GUIDANCE_SCALE) + else self._resolve_guidance_scale(sp, COSMOS3_T2V_DEFAULT_GUIDANCE_SCALE) ) flow_shift_target = float( transfer_config.flow_shift @@ -2821,11 +3008,11 @@ class Cosmos3OmniDiffusersPipeline( self._guidance_scale = guidance_scale self._num_timesteps = num_inference_steps - self._set_flow_shift(flow_shift_target, use_karras_sigmas=False) + self._set_flow_shift(flow_shift_target) generator = sp.generator + seed = self._resolve_seed(sp, generator) if generator is None: - seed = sp.seed if sp.seed is not None else 42 generator = torch.Generator(device=self.device).manual_seed(seed) cond_ids, cond_mask, uncond_ids, uncond_mask = self._format_and_tokenize_prompts( @@ -2915,7 +3102,11 @@ class Cosmos3OmniDiffusersPipeline( transfer_share_vision_temporal_positions=transfer_config.share_vision_temporal_positions, ) - self.scheduler.set_timesteps(num_inference_steps, device=self.device) + self._set_timesteps( + num_inference_steps, + device=self.device, + shift=self._current_flow_shift, + ) latents = self.diffuse_transfer( latents=latents, timesteps=self.scheduler.timesteps, @@ -2930,6 +3121,7 @@ class Cosmos3OmniDiffusersPipeline( shared_kwargs=shared_kwargs, velocity_mask=velocity_mask, condition_latents=condition_latents, + generator=generator, ) output_video = self._decode_latents(latents).clamp(-1, 1) previous_output = output_video @@ -3009,6 +3201,20 @@ class Cosmos3OmniDiffusersPipeline( action_enabled = action_mode is not None transfer_config = resolve_transfer_config(sp, prompt_data) action_video_tensor = video_tensor if action_enabled else None + is_v2v = video_tensor is not None and not is_t2i and not action_enabled + self._validate_distilled_generation_mode( + is_t2i=is_t2i, + image_tensor=image_tensor, + action_enabled=action_enabled, + transfer_config=transfer_config, + is_v2v=is_v2v, + sound_enabled=sound_enabled, + ) + self._validate_edge_generation_mode( + transfer_config=transfer_config, + is_v2v=is_v2v, + sound_enabled=sound_enabled, + ) if transfer_config is not None: if is_t2i: raise ValueError("Cosmos3 transfer inference is supported only for video outputs.") @@ -3056,31 +3262,36 @@ class Cosmos3OmniDiffusersPipeline( 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: - # T2I: 1024x1024, 50 steps, shift=3.0, guidance_interval=[400, 1000] - # T2V: 720x1280, 35 steps, shift=engine-init, no interval + # T2I and T2V share the same model and forward path; their defaults are: + # T2I: regular 1024x1024, Edge 640x640; 50 steps, shift=3.0, + # guidance_interval=[400, 1000] + # T2V: 189 frames and 35 steps; regular guidance=6, shift=10; + # Edge guidance=5, shift=3; + # no guidance interval if is_t2i: - height = sp.height or COSMOS3_T2I_DEFAULT_HEIGHT - width = sp.width or COSMOS3_T2I_DEFAULT_WIDTH + height = sp.height or ( + COSMOS3_EDGE_T2I_DEFAULT_HEIGHT if self.is_edge_model else COSMOS3_T2I_DEFAULT_HEIGHT + ) + width = sp.width or (COSMOS3_EDGE_T2I_DEFAULT_WIDTH if self.is_edge_model else COSMOS3_T2I_DEFAULT_WIDTH) num_frames = 1 num_inference_steps = sp.num_inference_steps or COSMOS3_T2I_DEFAULT_NUM_INFERENCE_STEPS - guidance_scale = sp.guidance_scale if sp.guidance_scale else COSMOS3_T2I_DEFAULT_GUIDANCE_SCALE + guidance_scale = self._resolve_guidance_scale(sp, COSMOS3_T2I_DEFAULT_GUIDANCE_SCALE) default_flow_shift = COSMOS3_T2I_DEFAULT_FLOW_SHIFT default_guidance_interval: tuple[float, float] | None = COSMOS3_T2I_DEFAULT_GUIDANCE_INTERVAL batch_size = max(1, int(sp.num_outputs_per_prompt or 1)) else: - height = sp.height or COSMOS3_T2V_DEFAULT_HEIGHT - width = sp.width or COSMOS3_T2V_DEFAULT_WIDTH + height = sp.height or ( + COSMOS3_EDGE_T2V_DEFAULT_HEIGHT if self.is_edge_model else COSMOS3_T2V_DEFAULT_HEIGHT + ) + width = sp.width or (COSMOS3_EDGE_T2V_DEFAULT_WIDTH if self.is_edge_model else COSMOS3_T2V_DEFAULT_WIDTH) + default_guidance_scale = ( + COSMOS3_EDGE_T2V_DEFAULT_GUIDANCE_SCALE if self.is_edge_model else COSMOS3_T2V_DEFAULT_GUIDANCE_SCALE + ) num_frames = sp.num_frames or COSMOS3_T2V_DEFAULT_NUM_FRAMES num_inference_steps = sp.num_inference_steps or COSMOS3_T2V_DEFAULT_NUM_INFERENCE_STEPS - guidance_scale = sp.guidance_scale if sp.guidance_scale else COSMOS3_T2V_DEFAULT_GUIDANCE_SCALE - # 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 = COSMOS3_V2V_DEFAULT_FLOW_SHIFT if is_v2v else self._engine_init_flow_shift + guidance_scale = self._resolve_guidance_scale(sp, default_guidance_scale) + default_flow_shift = self._engine_init_flow_shift default_guidance_interval = None batch_size = 1 # Existing video pipeline assumes B=1. @@ -3103,9 +3314,23 @@ class Cosmos3OmniDiffusersPipeline( f"or action_chunk_size + 1; got num_frames={num_frames}, action_chunk_size={action_chunk_size}." ) num_inference_steps = sp.num_inference_steps or 30 - guidance_scale = sp.guidance_scale if sp.guidance_scale is not None else 1.0 + guidance_scale = self._resolve_guidance_scale(sp, 1.0) default_flow_shift = 5.0 + if not is_t2i and not action_enabled: + requested_num_frames = int(num_frames) + num_frames = _ceil_video_num_frames( + requested_num_frames, + self.vae_scale_factor_temporal, + ) + if num_frames != requested_num_frames and _is_rank_zero(): + logger.info( + "Rounded Cosmos3 num_frames from %d to %d for temporal compression factor %d.", + requested_num_frames, + num_frames, + self.vae_scale_factor_temporal, + ) + domain_id = None if action_enabled: domain_id = resolve_domain_id( @@ -3146,13 +3371,12 @@ class Cosmos3OmniDiffusersPipeline( self._guidance_scale = guidance_scale self._num_timesteps = num_inference_steps - # Always resolve to a concrete target shift for this request, then - # update the shared Diffusers scheduler. - self._set_flow_shift(flow_shift_target, use_karras_sigmas=False if is_v2v else None) + # Always resolve to a concrete target shift for this request. + self._set_flow_shift(flow_shift_target) generator = sp.generator + seed = self._resolve_seed(sp, generator) if generator is None: - seed = sp.seed if sp.seed is not None else 42 generator = torch.Generator(device=self.device).manual_seed(seed) # --- Format prompts & tokenize (B=1; reused across loop iterations @@ -3293,7 +3517,11 @@ class Cosmos3OmniDiffusersPipeline( ) def _run_diffusion(start_latents): - self.scheduler.set_timesteps(num_inference_steps, device=self.device) + self._set_timesteps( + num_inference_steps, + device=self.device, + shift=self._current_flow_shift, + ) scheduler = self.scheduler return self.diffuse( latents=start_latents, @@ -3314,6 +3542,7 @@ class Cosmos3OmniDiffusersPipeline( guidance_interval=guidance_interval, raw_action_dim=raw_action_dim, scheduler=scheduler, + generator=generator, ) if is_t2i and batch_size > 1: diff --git a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py index 21e723ced..de9767d6f 100644 --- a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py @@ -396,7 +396,6 @@ class TimestepEmbedder(nn.Module): hidden_size: int, frequency_embedding_size: int = 256, max_period: int = 10000, - target_dtype: torch.dtype = torch.bfloat16, ) -> None: super().__init__() # Following diffusers naming pattern here for checkpoint compatibility. @@ -407,7 +406,7 @@ class TimestepEmbedder(nn.Module): self.hidden_size = hidden_size half = frequency_embedding_size // 2 - freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=target_dtype) / half) + freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half) self.register_buffer("freqs", freqs, persistent=False) def forward(self, t: torch.Tensor) -> torch.Tensor: @@ -587,6 +586,7 @@ class Cosmos3CrossAttention(nn.Module): head_dim: int, rms_norm_eps: float, quant_config: QuantizationConfig | None = None, + qk_norm: bool = True, prefix: str = "", ) -> None: super().__init__() @@ -636,8 +636,10 @@ class Cosmos3CrossAttention(nn.Module): prefix=f"{prefix}.to_out", ) - self.norm_q = RMSNorm(self.head_dim, eps=rms_norm_eps) - self.norm_k = RMSNorm(self.head_dim, eps=rms_norm_eps) + self.qk_norm = qk_norm + if self.qk_norm: + self.norm_q = RMSNorm(self.head_dim, eps=rms_norm_eps) + self.norm_k = RMSNorm(self.head_dim, eps=rms_norm_eps) self.attn = FrameworkAttention( num_heads=self.num_heads, @@ -721,8 +723,9 @@ class Cosmos3CrossAttention(nn.Module): v = self.to_v(hidden_states).view(B, S_gen, self.num_kv_heads_local, self.head_dim) # Per-head QK norm - q = F.rms_norm(q, (self.head_dim,), self.norm_q.weight, eps=self.norm_q.variance_epsilon) - k = F.rms_norm(k, (self.head_dim,), self.norm_k.weight, eps=self.norm_k.variance_epsilon) + if self.qk_norm: + q = F.rms_norm(q, (self.head_dim,), self.norm_q.weight, eps=self.norm_q.variance_epsilon) + k = F.rms_norm(k, (self.head_dim,), self.norm_k.weight, eps=self.norm_k.variance_epsilon) # Qwen3-style RoPE q, k = _apply_rotary_pos_emb(q, k, freqs_cos, freqs_sin) @@ -806,6 +809,8 @@ class Cosmos3GenDecoderLayer(nn.Module): head_dim: int, rms_norm_eps: float, quant_config: QuantizationConfig | None = None, + mlp_cls: type[nn.Module] = Cosmos3GatedMLP, + qk_norm: bool = True, prefix: str = "", ) -> None: super().__init__() @@ -817,11 +822,12 @@ class Cosmos3GenDecoderLayer(nn.Module): head_dim=head_dim, rms_norm_eps=rms_norm_eps, quant_config=quant_config, + qk_norm=qk_norm, prefix=f"{prefix}.cross_attention", ) self.input_layernorm = RMSNorm(hidden_size, eps=rms_norm_eps) self.post_attention_layernorm = RMSNorm(hidden_size, eps=rms_norm_eps) - self.mlp = Cosmos3GatedMLP( + self.mlp = mlp_cls( hidden_size=hidden_size, intermediate_size=intermediate_size, quant_config=quant_config, @@ -996,6 +1002,9 @@ class Cosmos3VFMTransformer(nn.Module): packed_modules_mapping = {} + _language_model_cls = Cosmos3LanguageModel + _gen_mlp_cls = Cosmos3GatedMLP + @staticmethod def _is_transformer_block(name: str, module) -> bool: return ("gen_layers" in name or "language_model.layers" in name) and name.split(".")[-1].isdigit() @@ -1032,6 +1041,25 @@ class Cosmos3VFMTransformer(nn.Module): if actual != expected: raise ValueError(f"Unsupported Cosmos3 transformer config: {key}={actual!r}; expected {expected!r}.") + @classmethod + def _resolve_rms_norm_eps(cls, model_config: Any) -> float: + return float(_tf_config_get(model_config, "rms_norm_eps", 1e-6)) + + @classmethod + def _resolve_rope_theta(cls, model_config: Any) -> float: + return float(_tf_config_get(model_config, "rope_theta", 5_000_000)) + + @classmethod + def _resolve_mrope_section(cls, model_config: Any) -> list[int]: + rope_scaling = _tf_config_get(model_config, "rope_scaling", {}) or {} + return list(rope_scaling.get("mrope_section", [24, 20, 20])) + + def _language_model_kwargs(self) -> dict[str, Any]: + return {} + + def validate_loaded_weights(self, loaded: set[str]) -> None: + del loaded + def __init__( self, od_config: OmniDiffusionConfig, @@ -1044,7 +1072,6 @@ class Cosmos3VFMTransformer(nn.Module): super().__init__() model_config = od_config.tf_model_config self._validate_supported_config(model_config) - rope_scaling = _tf_config_get(model_config, "rope_scaling", {}) or {} self.hidden_size = int(_tf_config_get(model_config, "hidden_size", 4096)) self.num_hidden_layers = int(_tf_config_get(model_config, "num_hidden_layers", 36)) @@ -1053,9 +1080,10 @@ class Cosmos3VFMTransformer(nn.Module): self.head_dim = int(_tf_config_get(model_config, "head_dim", 128)) self.intermediate_size = int(_tf_config_get(model_config, "intermediate_size", 12288)) self.vocab_size = int(_tf_config_get(model_config, "vocab_size", 151936)) - self.rms_norm_eps = float(_tf_config_get(model_config, "rms_norm_eps", 1e-6)) - self.rope_theta = float(_tf_config_get(model_config, "rope_theta", 5_000_000)) - self.mrope_section = list(rope_scaling.get("mrope_section", [24, 20, 20])) + self.rms_norm_eps = self._resolve_rms_norm_eps(model_config) + self.rope_theta = self._resolve_rope_theta(model_config) + self.mrope_section = self._resolve_mrope_section(model_config) + self.qk_norm_for_diffusion = bool(_tf_config_get(model_config, "qk_norm_for_diffusion", True)) self.latent_patch_size = int(_tf_config_get(model_config, "latent_patch_size", 2)) self.latent_channel_size = int(_tf_config_get(model_config, "latent_channel", 48)) self.timestep_scale = float(_tf_config_get(model_config, "timestep_scale", 0.001)) @@ -1095,10 +1123,12 @@ class Cosmos3VFMTransformer(nn.Module): ) self.patch_latent_dim = (self.latent_patch_size**2) * self.latent_channel_size + self.use_k_norm_und_for_gen = _tf_config_get(model_config, "use_k_norm_und_for_gen", None) + dtype = od_config.dtype quant_config = getattr(od_config, "quantization_config", None) if od_config else None - self.language_model = Cosmos3LanguageModel( + self.language_model = self._language_model_cls( hidden_size=self.hidden_size, intermediate_size=self.intermediate_size, num_hidden_layers=self.num_hidden_layers, @@ -1111,12 +1141,13 @@ class Cosmos3VFMTransformer(nn.Module): mrope_section=self.mrope_section, quant_config=quant_config, prefix="language_model", + **self._language_model_kwargs(), ) # Video projection layers are small; not worth quantizing. self.proj_in = nn.Linear(self.patch_latent_dim, self.hidden_size) self.proj_out = nn.Linear(self.hidden_size, self.patch_latent_dim) - self.time_embedder = TimestepEmbedder(self.hidden_size, target_dtype=dtype) + self.time_embedder = TimestepEmbedder(self.hidden_size) if self.action_gen: self.action_proj_in = DomainAwareLinear( self.action_dim, @@ -1147,6 +1178,8 @@ class Cosmos3VFMTransformer(nn.Module): head_dim=self.head_dim, rms_norm_eps=self.rms_norm_eps, quant_config=quant_config, + mlp_cls=self._gen_mlp_cls, + qk_norm=self.qk_norm_for_diffusion, prefix=f"gen_layers.{i}", ) for i in range(self.num_hidden_layers) diff --git a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3_edge.py b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3_edge.py new file mode 100644 index 000000000..6a1b4313b --- /dev/null +++ b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3_edge.py @@ -0,0 +1,396 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Cosmos3 Edge transformer variant with a Nemotron dense UND backbone.""" + +from __future__ import annotations + +from typing import Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from vllm.distributed import get_tensor_model_parallel_world_size +from vllm.model_executor.layers.linear import ColumnParallelLinear, RowParallelLinear +from vllm.model_executor.layers.quantization.base_config import QuantizationConfig + +from vllm_omni.diffusion.attention.layer import Attention as FrameworkAttention + +from .transformer_cosmos3 import ( + Cosmos3VFMTransformer, + Qwen3VLTextRotaryEmbedding, + RMSNorm, + _apply_rotary_pos_emb, + _nested_get, + _tf_config_get, +) + +COSMOS3_EDGE_BACKBONE_TYPE = "cosmos3_edge_nemotron_dense" + + +def _tf_config_nested_get(config: Any, key: str, default: Any = None) -> Any: + if config is None: + return default + if isinstance(config, dict): + value = _nested_get(config, key) + elif hasattr(config, "to_dict"): + value = _nested_get(config.to_dict(), key) + elif hasattr(config, "params"): + value = _nested_get(config.params, key) + else: + value = getattr(config, key, None) + return default if value is None else value + + +class Cosmos3Relu2MLP(nn.Module): + """Nemotron dense MLP: down_proj(relu(up_proj(x)) ** 2).""" + + def __init__( + self, + hidden_size: int, + intermediate_size: int, + quant_config: QuantizationConfig | None = None, + prefix: str = "", + ) -> None: + super().__init__() + self.up_proj = ColumnParallelLinear( + hidden_size, + intermediate_size, + bias=False, + gather_output=False, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.up_proj", + ) + self.down_proj = RowParallelLinear( + intermediate_size, + hidden_size, + bias=False, + input_is_parallel=True, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.down_proj", + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + hidden = F.relu(self.up_proj(x)) + return self.down_proj(hidden * hidden) + + +class Cosmos3EdgeCausalAttention(nn.Module): + """Edge UND causal attention. + + Reasoner self-attention uses raw UND Q/K with RoPE because + ``qk_norm_for_text=false``. The GEN-facing UND key cache is a separate + normalized-then-RoPE'd view of the raw K tensor when GEN QK norm is enabled; + no-GEN-QK-norm checkpoints reuse the raw RoPE'd UND K. + """ + + def __init__( + self, + *, + hidden_size: int, + num_attention_heads: int, + num_key_value_heads: int, + head_dim: int, + rms_norm_eps: float, + use_k_norm_und_for_gen: bool, + quant_config: QuantizationConfig | None = None, + prefix: str = "", + ) -> None: + super().__init__() + self.hidden_size = hidden_size + self.num_heads = num_attention_heads + self.num_kv_heads = num_key_value_heads + self.head_dim = head_dim + + tp_size = get_tensor_model_parallel_world_size() + self.num_heads_local = self.num_heads // tp_size + self.num_kv_heads_local = self.num_kv_heads // tp_size + + self.to_q = ColumnParallelLinear( + hidden_size, + self.num_heads * self.head_dim, + bias=False, + gather_output=False, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_q", + ) + self.to_k = ColumnParallelLinear( + hidden_size, + self.num_kv_heads * self.head_dim, + bias=False, + gather_output=False, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_k", + ) + self.to_v = ColumnParallelLinear( + hidden_size, + self.num_kv_heads * self.head_dim, + bias=False, + gather_output=False, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_v", + ) + self.to_out = RowParallelLinear( + self.num_heads * self.head_dim, + hidden_size, + bias=False, + input_is_parallel=True, + return_bias=False, + quant_config=quant_config, + prefix=f"{prefix}.to_out", + ) + + self.k_norm_und_for_gen = RMSNorm(self.head_dim, eps=rms_norm_eps) if use_k_norm_und_for_gen else None + self.attn = FrameworkAttention( + num_heads=self.num_heads, + head_size=self.head_dim, + causal=True, + softmax_scale=1.0 / (self.head_dim**0.5), + num_kv_heads=self.num_kv_heads, + skip_sequence_parallel=True, + ) + + def forward( + self, + hidden_states: torch.Tensor, + freqs_cos: torch.Tensor, + freqs_sin: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + B, S, _ = hidden_states.shape + + q = self.to_q(hidden_states).view(B, S, self.num_heads_local, self.head_dim) + k = self.to_k(hidden_states).view(B, S, self.num_kv_heads_local, self.head_dim) + v = self.to_v(hidden_states).view(B, S, self.num_kv_heads_local, self.head_dim) + + q_reasoner, k_reasoner = _apply_rotary_pos_emb(q, k, freqs_cos, freqs_sin) + + if self.k_norm_und_for_gen is not None: + k_gen = F.rms_norm( + k, + (self.head_dim,), + self.k_norm_und_for_gen.weight, + eps=self.k_norm_und_for_gen.variance_epsilon, + ) + _, k_gen = _apply_rotary_pos_emb(q, k_gen, freqs_cos, freqs_sin) + else: + k_gen = k_reasoner + + out = self.attn(q_reasoner, k_reasoner, v).reshape(B, S, -1) + return self.to_out(out), k_gen, v + + +class Cosmos3EdgeUndDecoderLayer(nn.Module): + """Edge UND decoder layer: causal self-attention + ReLU2 MLP.""" + + def __init__( + self, + *, + hidden_size: int, + intermediate_size: int, + num_attention_heads: int, + num_key_value_heads: int, + head_dim: int, + rms_norm_eps: float, + use_k_norm_und_for_gen: bool, + quant_config: QuantizationConfig | None = None, + prefix: str = "", + ) -> None: + super().__init__() + self.self_attn = Cosmos3EdgeCausalAttention( + hidden_size=hidden_size, + num_attention_heads=num_attention_heads, + num_key_value_heads=num_key_value_heads, + head_dim=head_dim, + rms_norm_eps=rms_norm_eps, + use_k_norm_und_for_gen=use_k_norm_und_for_gen, + quant_config=quant_config, + prefix=f"{prefix}.self_attn", + ) + self.input_layernorm = RMSNorm(hidden_size, eps=rms_norm_eps) + self.post_attention_layernorm = RMSNorm(hidden_size, eps=rms_norm_eps) + self.mlp = Cosmos3Relu2MLP( + hidden_size=hidden_size, + intermediate_size=intermediate_size, + quant_config=quant_config, + prefix=f"{prefix}.mlp", + ) + + def forward( + self, + hidden_states: torch.Tensor, + freqs: tuple[torch.Tensor, torch.Tensor], + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + residual = hidden_states + hidden_states = self.input_layernorm(hidden_states) + + cos, sin = freqs + attn_out, k, v = self.self_attn(hidden_states, cos, sin) + hidden_states = residual + attn_out + + residual = hidden_states + hidden_states = self.post_attention_layernorm(hidden_states) + hidden_states = residual + self.mlp(hidden_states) + + return hidden_states, k, v + + +class Cosmos3EdgeLanguageModel(nn.Module): + """Nemotron dense UND tower that returns GEN-facing K plus raw V per layer.""" + + _layerwise_offload_blocks_attrs = ["layers"] + + def __init__( + self, + *, + hidden_size: int, + intermediate_size: int, + num_hidden_layers: int, + num_attention_heads: int, + num_key_value_heads: int, + head_dim: int, + vocab_size: int, + rms_norm_eps: float, + rope_theta: float, + mrope_section: list[int], + use_k_norm_und_for_gen: bool, + quant_config: QuantizationConfig | None = None, + prefix: str = "", + ) -> None: + super().__init__() + self.embed_tokens = nn.Embedding(vocab_size, hidden_size) + self.rotary_emb = Qwen3VLTextRotaryEmbedding( + head_dim=head_dim, + rope_theta=rope_theta, + mrope_section=mrope_section, + ) + self.layers = nn.ModuleList( + [ + Cosmos3EdgeUndDecoderLayer( + hidden_size=hidden_size, + intermediate_size=intermediate_size, + num_attention_heads=num_attention_heads, + num_key_value_heads=num_key_value_heads, + head_dim=head_dim, + rms_norm_eps=rms_norm_eps, + use_k_norm_und_for_gen=use_k_norm_und_for_gen, + quant_config=quant_config, + prefix=f"{prefix}.layers.{i}", + ) + for i in range(num_hidden_layers) + ] + ) + self.norm = RMSNorm(hidden_size, eps=rms_norm_eps) + + def forward( + self, + text_ids: torch.Tensor, + freqs: tuple[torch.Tensor, torch.Tensor], + ) -> list[tuple[torch.Tensor, torch.Tensor]]: + hidden = self.embed_tokens(text_ids) + + cached_kv: list[tuple[torch.Tensor, torch.Tensor]] = [] + for layer in self.layers: + hidden, k, v = layer(hidden, freqs) + cached_kv.append((k, v)) + + return cached_kv + + +class Cosmos3EdgeVFMTransformer(Cosmos3VFMTransformer): + """Cosmos3 Edge variant with Nemotron dense UND and shared GEN diffusion.""" + + _language_model_cls = Cosmos3EdgeLanguageModel + _gen_mlp_cls = Cosmos3Relu2MLP + + @staticmethod + def _validate_supported_config(model_config: Any) -> None: + expected_values = { + "qk_norm_for_text": False, + "position_embedding_type": "unified_3d_mrope", + "unified_3d_mrope_reset_spatial_ids": True, + "joint_attn_implementation": "two_way", + } + for key, expected in expected_values.items(): + actual = _tf_config_get(model_config, key, expected) + if actual != expected: + raise ValueError( + f"Unsupported Cosmos3 Edge transformer config: {key}={actual!r}; expected {expected!r}." + ) + + backbone_type = _tf_config_get(model_config, "backbone_type", None) + if backbone_type is None: + raise ValueError( + f"Cosmos3 Edge transformer config must declare backbone_type={COSMOS3_EDGE_BACKBONE_TYPE!r}." + ) + if backbone_type != COSMOS3_EDGE_BACKBONE_TYPE: + raise ValueError( + "Unsupported Cosmos3 Edge transformer config: " + f"backbone_type={backbone_type!r}; expected {COSMOS3_EDGE_BACKBONE_TYPE!r}." + ) + + required_values = { + "latent_channel": 48, + "latent_patch_size": 2, + "temporal_compression_factor": 4, + } + for key, expected in required_values.items(): + actual = _tf_config_get(model_config, key, None) + if actual is None: + raise ValueError(f"Cosmos3 Edge transformer config must declare {key}={expected}.") + if int(actual) != expected: + raise ValueError( + f"Unsupported Cosmos3 Edge transformer config: {key}={actual!r}; expected {expected!r}." + ) + + @classmethod + def _resolve_rms_norm_eps(cls, model_config: Any) -> float: + return float( + _tf_config_get( + model_config, + "rms_norm_eps", + _tf_config_get(model_config, "layer_norm_epsilon", 1e-5), + ) + ) + + @classmethod + def _resolve_rope_theta(cls, model_config: Any) -> float: + return float(_tf_config_get(model_config, "rope_theta", 100_000_000)) + + @classmethod + def _resolve_mrope_section(cls, model_config: Any) -> list[int]: + mrope_section = _tf_config_get(model_config, "mrope_section", None) + if mrope_section is None: + mrope_section = _tf_config_nested_get(model_config, "mrope_section", [24, 20, 20]) + return list(mrope_section) + + def _language_model_kwargs(self) -> dict[str, Any]: + return {"use_k_norm_und_for_gen": self.use_k_norm_und_for_gen} + + def validate_loaded_weights(self, loaded: set[str]) -> None: + missing: list[str] = [] + for layer_idx in range(self.num_hidden_layers): + required_markers = ( + f"language_model.layers.{layer_idx}.mlp.up_proj.", + f"language_model.layers.{layer_idx}.mlp.down_proj.", + f"gen_layers.{layer_idx}.mlp.up_proj.", + f"gen_layers.{layer_idx}.mlp.down_proj.", + ) + if self.use_k_norm_und_for_gen: + required_markers = ( + *required_markers, + f"language_model.layers.{layer_idx}.self_attn.k_norm_und_for_gen.", + ) + missing.extend( + marker.rstrip(".") for marker in required_markers if not any(marker in name for name in loaded) + ) + if missing: + preview = ", ".join(missing[:10]) + suffix = " ..." if len(missing) > 10 else "" + raise ValueError( + "Cosmos3 Edge transformer checkpoint is missing required weights for " + f"{preview}{suffix}. Use a converted Edge checkpoint with ReLU2 UND/GEN MLP weights." + ) diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index 1454a37b1..5b8b2fba1 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -276,6 +276,11 @@ _DIFFUSION_MODELS = { "pipeline_cosmos3", "Cosmos3OmniDiffusersPipeline", ), + "Cosmos3OmniPipeline": ( + "cosmos3", + "pipeline_cosmos3", + "Cosmos3OmniDiffusersPipeline", + ), "SoulXSingerPipeline": ( "soulx_singer", "pipeline_soulx_singer_svs", @@ -536,6 +541,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "DreamIDOmniPipeline": "get_dreamid_omni_post_process_func", "SenseNovaU1Pipeline": "get_sensenova_u1_post_process_func", "Cosmos3OmniDiffusersPipeline": "get_cosmos3_post_process_func", + "Cosmos3OmniPipeline": "get_cosmos3_post_process_func", "HiDreamImagePipeline": "get_hidream_image_post_process_func", "StableDiffusionXLPipeline": "get_sdxl_image_post_process_func", "Krea2Pipeline": "get_krea2_post_process_func", @@ -546,6 +552,7 @@ _DIFFUSION_IR_OP_PRIORITY_FUNCS = { # `ir_op_priority_func` function must be placed in {mod_folder}/{mod_relname}.py, # where mod_folder and mod_relname are defined and mapped using `_DIFFUSION_MODELS` via the `arch` key. "Cosmos3OmniDiffusersPipeline": "get_cosmos3_ir_op_priority_func", + "Cosmos3OmniPipeline": "get_cosmos3_ir_op_priority_func", } _DIFFUSION_PRE_PROCESS_FUNCS = { @@ -570,6 +577,7 @@ _DIFFUSION_PRE_PROCESS_FUNCS = { "HunyuanImage3ForCausalMM": "get_hunyuan_image_3_pre_process_func", "MagiHumanPipeline": "get_magi_human_pre_process_func", "Cosmos3OmniDiffusersPipeline": "get_cosmos3_pre_process_func", + "Cosmos3OmniPipeline": "get_cosmos3_pre_process_func", "SoulXSingerPipeline": "get_soulxsinger_pre_process_func", "SoulXSingerSVCPipeline": "get_soulxsinger_svc_pre_process_func", } diff --git a/vllm_omni/model_extras/registry.py b/vllm_omni/model_extras/registry.py index 7103991c7..79f8b4360 100644 --- a/vllm_omni/model_extras/registry.py +++ b/vllm_omni/model_extras/registry.py @@ -153,6 +153,11 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = { "extra_output_params": COSMOS3_EXTRA_OUTPUT_PARAMS, "text_to_image_prompt_builder": build_cosmos3_text_to_image_prompt, }, + "Cosmos3OmniPipeline": { + "extra_body_params": COSMOS3_EXTRA_BODY_PARAMS, + "extra_output_params": COSMOS3_EXTRA_OUTPUT_PARAMS, + "text_to_image_prompt_builder": build_cosmos3_text_to_image_prompt, + }, "MagiHumanPipeline": { "extra_body_params": MAGI_HUMAN_EXTRA_BODY_PARAMS, "extra_output_params": MAGI_HUMAN_EXTRA_OUTPUT_PARAMS, From ae949d129f4d6dbf58640397b926dda252f30122 Mon Sep 17 00:00:00 2001 From: Alex Steiner <pst2154@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:57 -0700 Subject: [PATCH 097/101] [Bugfix][Quantization] Remap ModelOpt NVFP4 scale tensors (#5087) Signed-off-by: Alex Steiner <asteiner@nvidia.com> --- .../model_loader/test_modelopt_fp8_adapter.py | 49 +++++++++++++++++-- .../checkpoint_adapters/modelopt.py | 31 ++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py index 66fa87a3c..8c429a00f 100644 --- a/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py +++ b/tests/diffusion/model_loader/test_modelopt_fp8_adapter.py @@ -9,6 +9,7 @@ import torch.nn as nn from vllm_omni.diffusion.model_loader.checkpoint_adapters import ( ModelOptFp8CheckpointAdapter, + ModelOptNvFp4CheckpointAdapter, ) pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] @@ -89,6 +90,23 @@ class _QuantizedRemappedModelOptModel(nn.Module): return name +class _RemappedNvFp4Model(nn.Module): + def __init__(self) -> None: + super().__init__() + self.runtime = nn.Module() + self.runtime.proj = nn.Module() + self.runtime.proj.register_parameter( + "weight", + nn.Parameter(torch.empty(1, 1, dtype=torch.uint8), requires_grad=False), + ) + for name in ("input_scale", "weight_scale", "weight_scale_2"): + self.runtime.proj.register_parameter(name, nn.Parameter(torch.empty(1), requires_grad=False)) + + @staticmethod + def remap_checkpoint_key(name: str) -> str: + return name.replace("transformer.orig.proj.", "runtime.proj.") + + def _make_source() -> SimpleNamespace: return SimpleNamespace( subfolder="transformer", @@ -188,13 +206,36 @@ def test_modelopt_adapter_remaps_and_keeps_scales_for_quantized_target(): emitted = dict(adapted) assert len(adapted) == len(emitted) == 3 - # Both scales are retained (the dropped-scale regression). They keep their - # source-checkpoint names; the model's load_weights remaps them downstream. - assert torch.equal(emitted["transformer.orig.proj.weight_scale"], weight_scale) - assert torch.equal(emitted["transformer.orig.proj.input_scale"], input_scale) + # Both scales are retained and emitted under the remapped runtime names. + assert torch.equal(emitted["runtime.proj.weight_scale"], weight_scale) + assert torch.equal(emitted["runtime.proj.input_scale"], input_scale) # The FP8 weight is passed through unchanged under the remapped target name # (the quantized param is FP8, so it must NOT be dequantized). Compare via # float32 since torch.equal has no FP8 CPU kernel. assert emitted["runtime.proj.weight"].dtype == torch.float8_e4m3fn assert torch.equal(emitted["runtime.proj.weight"].to(torch.float32), fp8_weight.to(torch.float32)) + + +def test_modelopt_nvfp4_adapter_remaps_quantized_weights_and_scales(): + adapter = ModelOptNvFp4CheckpointAdapter(_RemappedNvFp4Model(), _make_source()) + prefix = "transformer.orig.proj" + checkpoint_tensors = [ + (f"{prefix}.input_scale", torch.tensor([1.0])), + (f"{prefix}.weight_scale", torch.tensor([0.5])), + (f"{prefix}.weight_scale_2", torch.tensor([0.25])), + (f"{prefix}.weight", torch.tensor([[1]], dtype=torch.uint8)), + ] + + adapted = list(adapter.adapt(iter(checkpoint_tensors))) + + assert [name for name, _ in adapted] == [ + name.replace(f"{prefix}.", "runtime.proj.") for name, _ in checkpoint_tensors + ] + + +def test_modelopt_nvfp4_adapter_rejects_unconsumed_pre_quant_scale(): + adapter = ModelOptNvFp4CheckpointAdapter(nn.Module(), _make_source()) + + with pytest.raises(ValueError, match="does not consume pre_quant_scale"): + list(adapter.adapt(iter([("transformer.proj.pre_quant_scale", torch.tensor([1.0]))]))) diff --git a/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py b/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py index d6f7b3e0c..68ec98d5a 100644 --- a/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py +++ b/vllm_omni/diffusion/model_loader/checkpoint_adapters/modelopt.py @@ -11,7 +11,12 @@ from vllm.model_executor.utils import get_packed_modules_mapping logger = init_logger(__name__) -MODEL_OPT_SCALE_SUFFIXES = (".input_scale", ".weight_scale", ".weight_scale_2", ".weight_scale_inv") +MODEL_OPT_SCALE_SUFFIXES = ( + ".input_scale", + ".weight_scale", + ".weight_scale_2", + ".weight_scale_inv", +) DEFAULT_PACKED_MODULES_MAPPING = { "to_qkv": ("to_q", "to_k", "to_v"), "add_kv_proj": ("add_q_proj", "add_k_proj", "add_v_proj"), @@ -122,6 +127,7 @@ class ModelOptFp8CheckpointAdapter: for candidate in self._weights_mapper.apply_list([name]): if candidate != name and candidate in self._loadable_tensors: + # Preserve shard names so downstream packed-weight loaders can route them. return candidate, name return None, name @@ -178,6 +184,7 @@ class ModelOptFp8CheckpointAdapter: def _handle_scale_tensor( self, name: str, + output_name: str, tensor: torch.Tensor, target_name: str | None, state: _AdaptState, @@ -186,7 +193,7 @@ class ModelOptFp8CheckpointAdapter: if target_name is None: state.skipped_scales += 1 else: - yield name, tensor + yield output_name, tensor yield from self._flush_pending_weights(name, state) def _target_dtype_for_dequantization( @@ -249,7 +256,7 @@ class ModelOptFp8CheckpointAdapter: for name, tensor in weights: target_name, output_name = self._resolve_target_and_output_names(name) if self._is_scale(name): - yield from self._handle_scale_tensor(name, tensor, target_name, state) + yield from self._handle_scale_tensor(name, output_name, tensor, target_name, state) continue target_dtype = self._target_dtype_for_dequantization(tensor, target_name) @@ -264,6 +271,8 @@ class ModelOptFp8CheckpointAdapter: class ModelOptNvFp4CheckpointAdapter(ModelOptFp8CheckpointAdapter): + _PRE_QUANT_SCALE_SUFFIX = ".pre_quant_scale" + @staticmethod def _is_checkpoint_quant_config(quant_config: object | None) -> bool: return ( @@ -273,6 +282,22 @@ class ModelOptNvFp4CheckpointAdapter(ModelOptFp8CheckpointAdapter): and bool(getattr(quant_config, "is_checkpoint_nvfp4_serialized", False)) ) + def adapt( + self, + weights: Iterable[tuple[str, torch.Tensor]], + ) -> Generator[tuple[str, torch.Tensor], None, None]: + def validated_weights() -> Generator[tuple[str, torch.Tensor], None, None]: + for name, tensor in weights: + if name.endswith(self._PRE_QUANT_SCALE_SUFFIX): + raise ValueError( + f"ModelOpt NVFP4 checkpoint tensor {name!r} is unsupported: " + "vLLM 0.25.0 does not consume pre_quant_scale. Export the checkpoint " + "with pre-quant scales folded into the weights." + ) + yield name, tensor + + yield from super().adapt(validated_weights()) + class ModelOptMixedPrecisionCheckpointAdapter(ModelOptFp8CheckpointAdapter): @staticmethod From 07957e12f45611363970f717dd6be080f5c7b71c Mon Sep 17 00:00:00 2001 From: bastefaniak <bstefaniak@nvidia.com> Date: Fri, 17 Jul 2026 00:01:21 +0200 Subject: [PATCH 098/101] Fix FP8 Quack GEMM call when inference_mode() and torch.compile() is used. (#5153) Signed-off-by: Bartosz Stefaniak <bstefaniak@nvidia.com> Co-authored-by: Roger Wang <hey@rogerw.io> --- vllm_omni/quantization/quack_fp8.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vllm_omni/quantization/quack_fp8.py b/vllm_omni/quantization/quack_fp8.py index 8c7251e43..865958386 100644 --- a/vllm_omni/quantization/quack_fp8.py +++ b/vllm_omni/quantization/quack_fp8.py @@ -94,7 +94,7 @@ def quack_scaled_fp8_mm( 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) + gemm.gemm(a, b, out=out, bias=bias, alpha=alpha, tuned=True) return out From be517db18a68fb8983ce72a4a5f5312f5324b26e Mon Sep 17 00:00:00 2001 From: linzhenpl07 <60651557+linzhenpl07@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:26:35 +0800 Subject: [PATCH 099/101] examples: replica data-parallel recipe + benchmark for video DiT (#4707) (#5052) Signed-off-by: linzhenpl07 <linzhenpl07@gmail.com> --- .../replica_data_parallel/README.md | 91 +++++++ .../replica_data_parallel/bench_replica_dp.py | 241 ++++++++++++++++++ .../replica_data_parallel/run_server.sh | 33 +++ .../replica_data_parallel/wan2_2_ti2v_dp.yaml | 43 ++++ vllm_omni/entrypoints/cli/serve.py | 3 +- 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 examples/online_serving/replica_data_parallel/README.md create mode 100755 examples/online_serving/replica_data_parallel/bench_replica_dp.py create mode 100755 examples/online_serving/replica_data_parallel/run_server.sh create mode 100644 examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml diff --git a/examples/online_serving/replica_data_parallel/README.md b/examples/online_serving/replica_data_parallel/README.md new file mode 100644 index 000000000..4bc98d6f7 --- /dev/null +++ b/examples/online_serving/replica_data_parallel/README.md @@ -0,0 +1,91 @@ +# Replica Data Parallelism for Video Generation + +A runnable recipe + benchmark for **replica data parallelism** (replica-DP) on a +video DiT. Replica-DP runs `N` independent diffusion engine replicas, one per GPU, +and routes each request to a single replica. It scales **request throughput** +near-linearly; it does **not** change single-request latency (for that, use an +intra-request axis such as tensor/sequence/CFG parallelism). + +This example uses `Wan-AI/Wan2.2-TI2V-5B-Diffusers` with one GPU per replica. + +## Files + +| File | Purpose | +|------|---------| +| `wan2_2_ti2v_dp.yaml` | Diffusion stage config; `runtime.num_replicas` + `runtime.devices` drive the fan-out. | +| `run_server.sh` | Substitutes `NUM_REPLICAS` / `DEVICES` into the config and serves. | +| `bench_replica_dp.py` | Replica-agnostic load driver; reports throughput, latency, and a per-input isolation check. | + +## Run + +Start the server with the replica count you want (one GPU per replica): + +```bash +# baseline +NUM_REPLICAS=1 DEVICES=0 ./run_server.sh +# 2 replicas +NUM_REPLICAS=2 DEVICES=0,1 ./run_server.sh +# 4 replicas +NUM_REPLICAS=4 DEVICES=0,1,2,3 ./run_server.sh +``` + +In another shell, drive a fixed workload and read the throughput: + +```bash +python bench_replica_dp.py --url http://127.0.0.1:8098 \ + --num-requests 28 --concurrency 8 --label "replicas=4" +``` + +Re-run the server at `NUM_REPLICAS=1,2,4` (same client workload each time) and +compare `videos/min`. Pass `--baseline-tpm <N=1 value>` to print a scaling factor. + +### Isolation check + +Identical outputs under a *shared* prompt+seed prove nothing -- every request +would be identical regardless of isolation. Instead each request gets a distinct +input (a prompt from the pool + a unique seed `base_seed+i`), and we check that a +request's output is unchanged whether it runs alone or concurrently amid other +distinct requests. Capture a per-input baseline on `N=1`, then verify it under +`N>=2` concurrency: + +```bash +# 1) against NUM_REPLICAS=1 (serial), record each input's output hash +python bench_replica_dp.py --num-requests 8 --concurrency 1 \ + --write-baseline baseline.json + +# 2) against NUM_REPLICAS>=2, replay the SAME inputs concurrently and verify +python bench_replica_dp.py --num-requests 8 --concurrency 8 \ + --check-baseline baseline.json +``` + +Step 2 exits non-zero if any request's output differs from its own baseline (a +mismatch means cross-request state bleed or misrouting). Keep `--num-requests`, +`--base-seed`, and the prompt source identical between the two runs so the input +sets match. + +## Measured scaling + +Wan2.2-TI2V-5B (832×480, 33 frames, 30 steps), 4× A800-80GB (NVLink), one GPU per replica: + +| Replicas | Throughput (videos/min) | Scaling | Efficiency | +|----------|-------------------------|---------|------------| +| 1 | 4.71 | 1.00× | — | +| 2 | 9.20 | 1.95× | 98% | +| 4 | 18.02 | 3.83× | 96% | + +Per-request latency stayed flat (~13–14 s) across all replica counts. Isolation +is checked per-input via the baseline/verify protocol above: each request's +output under `N>=2` concurrency must match its own single-replica baseline. + +## Notes on the config surface + +- `runtime.num_replicas: N` fans out `N` replicas; `runtime.devices` assigns their + GPUs. With `tensor_parallel_size = 1`, list one GPU per replica + (`num_replicas * tensor_parallel_size` entries, pool mode). +- Replica fan-out here comes from the stage config, not a CLI flag. (The headless / + multi-runtime launch path uses the process-local `--omni-dp-size-local`, which + requires `--stage-id`.) +- This recipe serves via `--stage-configs-path`. Expressing replica-DP under the + newer `--deploy-config` schema is part of the ongoing serving-config alignment + (see the diffusion parallelism guide and the `#4707` discussion); `num_replicas` + is accepted by both paths. diff --git a/examples/online_serving/replica_data_parallel/bench_replica_dp.py b/examples/online_serving/replica_data_parallel/bench_replica_dp.py new file mode 100755 index 000000000..977f475ac --- /dev/null +++ b/examples/online_serving/replica_data_parallel/bench_replica_dp.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Throughput-scaling + isolation benchmark for diffusion replica data parallelism. + +Sends `--num-requests` text-to-video requests (up to `--concurrency` in flight) +to a running vLLM-Omni video server via `POST /v1/videos/sync` (blocks until the +clip is produced) and reports: + + completed / failed, makespan (wall clock), throughput (videos/min), + per-request latency p50/p90/max (nearest-rank), and -- when a baseline + manifest is supplied -- a per-request isolation check. + +Each request gets a *distinct* input (a prompt from the pool + a unique seed), +so an output is a deterministic function of its own input and nothing else. + +Measuring replica scaling +------------------------- +This client is replica-agnostic. Vary the *server's* replica count +(`runtime.num_replicas` in the stage config; see run_server.sh) and keep the +client workload fixed, then compare throughput: + + N=1 -> baseline + N=2 -> expect ~2x + N=4 -> expect ~4x (near-linear is the goal) + +Pass the single-replica videos/min via `--baseline-tpm` to print a scaling +factor for the current run. + +Isolation / correctness +----------------------- +Byte-identical outputs under a *shared* prompt+seed prove nothing -- every +request would be identical regardless of isolation. Instead, give each request a +distinct input and check that its output is unchanged whether it runs alone or +concurrently amid other distinct requests: + + 1. Against an N=1 server, capture a per-input baseline: + bench_replica_dp.py --num-requests 8 --concurrency 1 \ + --write-baseline baseline.json + 2. Against an N>=2 server, replay the *same* inputs concurrently and verify + each output matches its own baseline: + bench_replica_dp.py --num-requests 8 --concurrency 8 \ + --check-baseline baseline.json + +A mismatch means a request's output changed under concurrency -- cross-request +state bleed or misrouting -- and the run exits non-zero. + +Requires: requests (`pip install requests`). +""" + +import argparse +import concurrent.futures as cf +import hashlib +import json +import math +import sys +import time +from pathlib import Path + +import requests + +# Distinct prompts, cycled across requests; combined with a per-request unique +# seed this makes every request a distinct input (see input_key()). +DEFAULT_PROMPTS = [ + "A cat playing piano, cinematic, high detail", + "A drone shot over a snowy mountain range at sunrise", + "A red sports car driving through a neon-lit city at night", + "Ocean waves crashing on a rocky shore, slow motion", + "A hot air balloon floating over green rolling hills", + "Timelapse of clouds moving over a desert canyon at dusk", + "A steam train crossing a stone viaduct in autumn", + "A jellyfish drifting through deep blue water, backlit", +] + + +def input_key(prompt, seed, args): + """Stable identity of a request's input; matches baseline<->verify by content, not order.""" + return "\x1f".join([prompt, str(seed), args.size, str(args.num_frames), str(args.steps)]) + + +def nearest_rank(xs, q): + """Nearest-rank percentile; q in [0, 1]. Correct for tiny samples (unlike int(q*n)-1).""" + if not xs: + return float("nan") + s = sorted(xs) + rank = max(1, math.ceil(q * len(s))) + return s[rank - 1] + + +def one_request(args, spec, idx, save): + """Fire one /v1/videos/sync request; return (ok, latency, nbytes, md5, key, err).""" + prompt, seed = spec["prompt"], spec["seed"] + key = input_key(prompt, seed, args) + # multipart form fields match POST /v1/videos/sync in the omni API server + form = { + "prompt": (None, prompt), + "size": (None, args.size), # e.g. "832x480" + "num_frames": (None, str(args.num_frames)), + "num_inference_steps": (None, str(args.steps)), + "seed": (None, str(seed)), # per-request unique -> distinct, reproducible input + } + if args.model: + form["model"] = (None, args.model) + if args.negative_prompt: + form["negative_prompt"] = (None, args.negative_prompt) + + t0 = time.perf_counter() + try: + r = requests.post(f"{args.url}/v1/videos/sync", files=form, timeout=args.timeout) + dt = time.perf_counter() - t0 + if r.status_code != 200: + return (False, dt, 0, "", key, f"HTTP {r.status_code}: {r.text[:160]}") + body = r.content + md5 = hashlib.md5(body).hexdigest() + if save and args.save_dir: + Path(args.save_dir).mkdir(parents=True, exist_ok=True) + (Path(args.save_dir) / f"req{idx:03d}_seed{seed}.mp4").write_bytes(body) + return (True, dt, len(body), md5, key, "") + except Exception as e: # noqa: BLE001 - report any client-side failure + return (False, time.perf_counter() - t0, 0, "", key, repr(e)) + + +def build_specs(args): + """Deterministic list of distinct (prompt, seed) inputs; identical across baseline/verify runs.""" + prompts = DEFAULT_PROMPTS + if args.prompts_file: + prompts = [ln.strip() for ln in Path(args.prompts_file).read_text().splitlines() if ln.strip()] + return [{"prompt": prompts[i % len(prompts)], "seed": args.base_seed + i} for i in range(args.num_requests)] + + +def run_batch(args, specs, save): + """Fire specs concurrently; return (results, makespan). results = list of one_request tuples.""" + results = [] + wall0 = time.perf_counter() + with cf.ThreadPoolExecutor(max_workers=args.concurrency) as ex: + futs = {ex.submit(one_request, args, spec, i, save): i for i, spec in enumerate(specs)} + for done, f in enumerate(cf.as_completed(futs), 1): + res = f.result() + results.append(res) + good, dt, nbytes, _md5, _key, err = res + print(f" [{done}/{len(specs)}] {'ok' if good else 'FAIL'} {dt:6.1f}s {(nbytes / 1e6):5.1f}MB {err}") + return results, time.perf_counter() - wall0 + + +def main(): + ap = argparse.ArgumentParser(description="Diffusion replica-DP throughput + isolation benchmark") + ap.add_argument("--url", default="http://127.0.0.1:8098", help="server base URL (no path)") + ap.add_argument("--num-requests", type=int, default=16, help="total requests to send") + ap.add_argument("--concurrency", type=int, default=4, help="max in-flight (set >= replica count)") + ap.add_argument("--warmup", type=int, default=-1, help="warmup requests before timing (default: =concurrency)") + ap.add_argument("--base-seed", type=int, default=42, help="request i uses seed base_seed+i (distinct inputs)") + ap.add_argument("--prompts-file", default="", help="optional file, one prompt per line (else a built-in pool)") + ap.add_argument("--negative-prompt", default="") + ap.add_argument("--size", default="832x480", help="WIDTHxHEIGHT") + ap.add_argument("--num-frames", type=int, default=33) + ap.add_argument("--steps", type=int, default=30, help="num_inference_steps") + ap.add_argument("--model", default="", help="optional; defaults to the served model") + ap.add_argument("--timeout", type=float, default=1800, help="per-request timeout (s)") + ap.add_argument("--save-dir", default="", help="save returned clips") + ap.add_argument("--label", default="", help="run label, e.g. 'replicas=2'") + ap.add_argument( + "--baseline-tpm", type=float, default=0.0, help="single-replica videos/min, to print a scaling factor" + ) + ap.add_argument("--write-baseline", default="", help="run against N=1: write per-input md5 manifest to this path") + ap.add_argument("--check-baseline", default="", help="run against N>=2: verify each output matches this manifest") + args = ap.parse_args() + + if args.write_baseline and args.check_baseline: + ap.error("--write-baseline and --check-baseline are mutually exclusive") + + specs = build_specs(args) + warmup = args.concurrency if args.warmup < 0 else args.warmup + + print( + f"-> {args.url}/v1/videos/sync | {args.num_requests} reqs " + f"| concurrency {args.concurrency} | warmup {warmup} | {args.size} {args.num_frames}f {args.steps}steps " + f"| {args.label}" + ) + + # Warmup: absorb each replica's first-call compile/init cost so it does not + # skew the measured makespan. Results are discarded and never saved. + if warmup > 0: + print(f"warming up ({warmup} reqs, not timed)...") + run_batch(args, [specs[i % len(specs)] for i in range(warmup)], save=False) + + results, makespan = run_batch(args, specs, save=True) + + ok = sum(1 for r in results if r[0]) + fail = len(results) - ok + lat = [r[1] for r in results if r[0]] + errs = [r[5] for r in results if not r[0]] + got = {r[4]: r[3] for r in results if r[0]} # input_key -> md5 + + print(f"\n===== result {args.label} =====") + print(f"completed {ok} / failed {fail} | makespan {makespan:.1f}s") + if ok: + tpm = ok / makespan * 60.0 + print(f"throughput: {tpm:.2f} videos/min ({ok / makespan:.4f} videos/s)") + print( + f"latency (s): p50={nearest_rank(lat, 0.50):.1f} " + f"p90={nearest_rank(lat, 0.90):.1f} " + f"min={min(lat):.1f} max={max(lat):.1f}" + ) + if args.baseline_tpm > 0: + print(f"scaling vs baseline ({args.baseline_tpm:.2f}/min): {tpm / args.baseline_tpm:.2f}x") + if errs: + print("sample failures:", errs[:3]) + + # Isolation check: compare each request's output against its own single-replica baseline. + isolation_failed = False + if args.write_baseline: + Path(args.write_baseline).write_text(json.dumps(got, indent=2)) + print(f"baseline: wrote {len(got)} per-input hashes -> {args.write_baseline}") + elif args.check_baseline: + baseline = json.loads(Path(args.check_baseline).read_text()) + matched = mismatched = unknown = 0 + for key, md5 in got.items(): + if key not in baseline: + unknown += 1 + print(f" isolation: NO BASELINE for input {key!r} (not in the baseline manifest)") + elif baseline[key] == md5: + matched += 1 + else: + mismatched += 1 + print(f" isolation: MISMATCH for input {key!r} (output changed under concurrency)") + # PASS requires the run to cover the *whole* baseline: a subset (fewer requests, or + # some failed) must not pass just because the inputs it did run happened to match. + unverified = sorted(set(baseline) - set(got)) + for key in unverified: + print(f" isolation: NOT VERIFIED for baseline input {key!r} (absent from this run)") + isolation_failed = mismatched > 0 or unknown > 0 or len(unverified) > 0 + verdict = "PASS (isolated)" if not isolation_failed else "FAIL" + print( + f"isolation: {matched} matched / {mismatched} mismatched / {unknown} unknown / " + f"{len(unverified)} unverified vs baseline({len(baseline)}) -> {verdict}" + ) + + if fail > 0 or ok == 0 or isolation_failed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/replica_data_parallel/run_server.sh b/examples/online_serving/replica_data_parallel/run_server.sh new file mode 100755 index 000000000..915732eee --- /dev/null +++ b/examples/online_serving/replica_data_parallel/run_server.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Serve N independent Wan2.2 TI2V-5B replicas (replica data parallelism). +# +# Usage: +# NUM_REPLICAS=1 DEVICES=0 ./run_server.sh # baseline +# NUM_REPLICAS=2 DEVICES=0,1 ./run_server.sh +# NUM_REPLICAS=4 DEVICES=0,1,2,3 ./run_server.sh +# +# Then drive load with bench_replica_dp.py (see README.md) and compare +# throughput across replica counts. +set -euo pipefail + +MODEL="${MODEL:-Wan-AI/Wan2.2-TI2V-5B-Diffusers}" +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8098}" +NUM_REPLICAS="${NUM_REPLICAS:-1}" +DEVICES="${DEVICES:-0}" # one GPU per replica for tensor_parallel_size=1 + +HERE="$(cd "$(dirname "$0")" && pwd)" +TEMPLATE="$HERE/wan2_2_ti2v_dp.yaml" +CFG="$(mktemp --suffix=_wan2_2_dp.yaml)" +trap 'rm -f "$CFG"' EXIT + +sed -e "s/__NUM_REPLICAS__/${NUM_REPLICAS}/" \ + -e "s/__DEVICES__/${DEVICES}/" \ + "$TEMPLATE" > "$CFG" + +echo "Serving ${MODEL}: num_replicas=${NUM_REPLICAS} devices=${DEVICES} (config ${CFG})" + +vllm serve "$MODEL" --omni \ + --host "$HOST" \ + --port "$PORT" \ + --stage-configs-path "$CFG" diff --git a/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml b/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml new file mode 100644 index 000000000..b472d7e2d --- /dev/null +++ b/examples/online_serving/replica_data_parallel/wan2_2_ti2v_dp.yaml @@ -0,0 +1,43 @@ +# Replica data-parallel recipe for Wan2.2 TI2V-5B (video DiT). +# +# Fans out `num_replicas` independent DiffusionEngine replicas, one per GPU, +# and lets the serving layer route each request to a single replica. This +# scales request throughput near-linearly; it does not change single-request +# latency. See ./README.md for measured numbers and the `run_server.sh` driver. +# +# run_server.sh substitutes __NUM_REPLICAS__ / __DEVICES__ from env before serving. +# For tensor_parallel_size = 1, `devices` lists one GPU per replica (pool mode): +# num_replicas=4 -> devices "0,1,2,3". + +stage_args: + - stage_id: 0 + stage_type: diffusion + runtime: + num_replicas: __NUM_REPLICAS__ + devices: "__DEVICES__" + max_batch_size: 1 + engine_args: + model_stage: dit + # `model_class_name` takes the diffusion registry KEY (WanPipeline), which + # resolves to the Wan22Pipeline class. Wan2.2 TI2V-5B is served by the base + # WanPipeline, which auto-detects TI2V-5B mode from model_index.json. + model_class_name: WanPipeline + max_num_seqs: 1 + enforce_eager: true + trust_remote_code: true + distributed_executor_backend: "mp" + 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/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index 5d435bc2f..c1ca73e44 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -148,7 +148,8 @@ class OmniServeCommand(CLISubcommand): "The following CLI args are not supported under --omni: " f"{', '.join(offenders)}. Configure parallelism through the " "per-stage YAML (`--deploy-config` / `--stage-configs-path`) " - "and replica count via `--omni-dp-size-local`." + "and replica count via the per-stage `num_replicas` config field " + "(single-runtime) or `--omni-dp-size-local` (headless / multi-runtime)." ) # --omni-lb-policy is validated against the LoadBalancingPolicy enum. From 7aa5c9a0901b7b9254052c4d342d0c3fa447eb95 Mon Sep 17 00:00:00 2001 From: Weiming Liao <liaowm5@gmail.com> Date: Fri, 17 Jul 2026 17:32:48 +0800 Subject: [PATCH 100/101] [CI][NPU]: Add nightly performance test for NPU - Qwen3-TTS on A3 (#5158) Signed-off-by: Weiming Liao <liaowm5@gmail.com> Co-authored-by: wangyu <53896905+yenuo26@users.noreply.github.com> --- .buildkite/pipeline-npu.yaml | 87 +++++++++++++++++++++++++++++- .buildkite/test-npu-nightly.yaml | 28 ++++++++++ docker/Dockerfile.npu.ci | 2 + docker/Dockerfile.npu.ci.a3 | 2 + tests/dfx/perf/tests/test_tts.json | 3 +- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/.buildkite/pipeline-npu.yaml b/.buildkite/pipeline-npu.yaml index 161b0e944..d035ffc94 100644 --- a/.buildkite/pipeline-npu.yaml +++ b/.buildkite/pipeline-npu.yaml @@ -78,8 +78,89 @@ steps: echo "--- Image pushed successfully" echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + - label: ":buildkit: Build and Push NPU Test Image - A3" + key: image-build-a3 + agents: + queue: "ascend-a2b3" + resource_class: "npu-1" + plugins: + - kubernetes: + metadata: + annotations: + vault.hashicorp.com/agent-init-first: "true" + vault.hashicorp.com/agent-inject: "true" + vault.hashicorp.com/agent-inject-perms-ca.pem: "0400" + vault.hashicorp.com/agent-inject-perms-cert.pem: "0400" + vault.hashicorp.com/agent-inject-perms-config.json: "0400" + vault.hashicorp.com/agent-inject-perms-key.pem: "0400" + vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd + vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}" + vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}" + vault.hashicorp.com/agent-pre-populate-only: "true" + vault.hashicorp.com/agent-run-as-group: "1000" + vault.hashicorp.com/agent-run-as-user: "1000" + vault.hashicorp.com/agent-service-account-token-volume-name: token-vol + vault.hashicorp.com/role: ascend-gha-runners + vault.hashicorp.com/secret-volume-path: /home/user/.docker/ + vault.hashicorp.com/tls-skip-verify: "true" + podSpecPatch: + volumes: + - name: token-vol + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + audience: api + expirationSeconds: 600 + path: token + env: + VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}-a3" + IMAGE_NAME: "vllm-omni-ci-npu" + IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" + BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" + command: | + set -ex + + echo "--- Building and pushing NPU Test Image" + echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + echo "buildkitd address: $${BUILDKITD_ADDR}" + + if ! command -v buildctl &> /dev/null; then + echo "Installing buildctl..." + mkdir -p /tmp/buildkit + BUILDKIT_VERSION="v0.29.0" + wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz + tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit + cp /tmp/buildkit/bin/buildctl /usr/local/bin/ + fi + + export DOCKER_CONFIG=/home/user/.docker + buildctl \ + --addr="$${BUILDKITD_ADDR}" \ + --tlscacert=/home/user/.docker/ca.pem \ + --tlscert=/home/user/.docker/cert.pem \ + --tlskey=/home/user/.docker/key.pem \ + build \ + --frontend dockerfile.v0 \ + --local context=. \ + --local dockerfile=./docker \ + --opt filename=Dockerfile.npu.ci.a3 \ + --secret id=dockerconfig,src=/home/user/.docker/config.json \ + --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ + --progress=plain + + echo "--- Image pushed successfully" + echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + - label: "Upload Ready Pipeline" - depends_on: image-build + depends_on: + - image-build + - image-build-a3 key: upload-ready-pipeline if: >- build.branch != "main" && ( @@ -94,7 +175,9 @@ steps: # L4 Test — main+NIGHTLY=1 (scheduled), or PR with specific label (e.g. add label then Rebuild) - label: "Upload Nightly Pipeline" - depends_on: image-build + depends_on: + - image-build + - image-build-a3 key: upload-nightly-pipeline if: >- (build.branch == "main" && build.env("NIGHTLY") == "1") || diff --git a/.buildkite/test-npu-nightly.yaml b/.buildkite/test-npu-nightly.yaml index 2c2372f4c..4dbda17f9 100644 --- a/.buildkite/test-npu-nightly.yaml +++ b/.buildkite/test-npu-nightly.yaml @@ -22,3 +22,31 @@ steps: HF_TOKEN: "${HF_TOKEN}" commands: - pytest -s -v tests/e2e/online_serving/test_wan22_expansion.py -k npu_i2v --run-level "full_model" + - group: ":card_index_dividers: TTS Model Test" + key: nightly-tts-test-group + depends_on: upload-nightly-pipeline + if: build.env("NIGHTLY") == "1" || build.pull_request.labels includes "nightly-test" || build.pull_request.labels includes "tts-test" + steps: + - label: ":full_moon: TTS · Perf Test" + agents: + queue: "ascend-a3" + resource_class: "npu-2" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" + plugins: + - kubernetes: + podSpecPatch: + imagePullSecrets: + - name: swr-secret + env: + VLLM_WORKER_MULTIPROC_METHOD: spawn + VLLM_ALLOW_LONG_MAX_MODEL_LEN: "1" + HF_TOKEN: "${HF_TOKEN}" + commands: + - export BENCHMARK_DIR=tests/dfx/perf/results + - export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1" + - | + set +e + pytest -s -v tests/dfx/perf/scripts/run_benchmark.py -m npu --test-config-file tests/dfx/perf/tests/test_tts.json + EXIT=$$? + buildkite-agent artifact upload "tests/dfx/perf/results/*.json" + exit $$EXIT diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci index f5c3cdfaf..a3cede3a7 100644 --- a/docker/Dockerfile.npu.ci +++ b/docker/Dockerfile.npu.ci @@ -6,6 +6,8 @@ ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} COPY . . +RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list + RUN apt-get update && \ apt-get install -y --no-install-recommends \ ffmpeg \ diff --git a/docker/Dockerfile.npu.ci.a3 b/docker/Dockerfile.npu.ci.a3 index 6a8ac4678..1369d8c85 100644 --- a/docker/Dockerfile.npu.ci.a3 +++ b/docker/Dockerfile.npu.ci.a3 @@ -6,6 +6,8 @@ ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} COPY . . +RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list + RUN apt-get update && \ apt-get install -y --no-install-recommends \ ffmpeg \ diff --git a/tests/dfx/perf/tests/test_tts.json b/tests/dfx/perf/tests/test_tts.json index 3bbf60935..85236eab7 100644 --- a/tests/dfx/perf/tests/test_tts.json +++ b/tests/dfx/perf/tests/test_tts.json @@ -5,7 +5,8 @@ { "hardware_marks": { "res": { - "cuda": "H100" + "cuda": "H100", + "npu": "A3" }, "num_cards": 2 } From d09f549e862c58ca87c195d4050e77b20a126a38 Mon Sep 17 00:00:00 2001 From: bastefaniak <bstefaniak@nvidia.com> Date: Fri, 17 Jul 2026 20:38:04 +0200 Subject: [PATCH 101/101] [bugfix] Use FlowMatchEulerDiscreteScheduler from diffusers 0.39.0 with correct RNG handling for upcoming Cosmos3 distilled models (#5176) Signed-off-by: Bartosz Stefaniak <bstefaniak@nvidia.com> --- .../models/cosmos3/pipeline_cosmos3.py | 4 +- .../diffusion/models/schedulers/__init__.py | 4 + .../scheduling_flow_match_euler_discrete.py | 660 ++++++++++++++++++ 3 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 vllm_omni/diffusion/models/schedulers/scheduling_flow_match_euler_discrete.py diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index 58e74ccf4..41a3e80fc 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -40,7 +40,6 @@ from typing import Any, ClassVar import numpy as np import PIL.Image import torch -from diffusers import FlowMatchEulerDiscreteScheduler from diffusers.utils.torch_utils import randn_tensor from diffusers.video_processor import VideoProcessor from torch import nn @@ -62,6 +61,9 @@ from vllm_omni.diffusion.models.interface import ( SupportsComponentDiscovery, ) from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_zero +from vllm_omni.diffusion.models.schedulers.scheduling_flow_match_euler_discrete import ( + FlowMatchEulerDiscreteScheduler, +) from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( FlowUniPCMultistepScheduler, ) diff --git a/vllm_omni/diffusion/models/schedulers/__init__.py b/vllm_omni/diffusion/models/schedulers/__init__.py index e683ed272..11aedcb1d 100644 --- a/vllm_omni/diffusion/models/schedulers/__init__.py +++ b/vllm_omni/diffusion/models/schedulers/__init__.py @@ -2,11 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from vllm_omni.diffusion.models.schedulers.scheduling_dmd2_euler import DMD2EulerScheduler +from vllm_omni.diffusion.models.schedulers.scheduling_flow_match_euler_discrete import ( + FlowMatchEulerDiscreteScheduler, +) from vllm_omni.diffusion.models.schedulers.scheduling_flow_unipc_multistep import ( FlowUniPCMultistepScheduler, ) __all__ = [ "DMD2EulerScheduler", + "FlowMatchEulerDiscreteScheduler", "FlowUniPCMultistepScheduler", ] diff --git a/vllm_omni/diffusion/models/schedulers/scheduling_flow_match_euler_discrete.py b/vllm_omni/diffusion/models/schedulers/scheduling_flow_match_euler_discrete.py new file mode 100644 index 000000000..8aa7d3a00 --- /dev/null +++ b/vllm_omni/diffusion/models/schedulers/scheduling_flow_match_euler_discrete.py @@ -0,0 +1,660 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +# Originally from https://github.com/huggingface/diffusers/blob/v0.39.0/src/diffusers/schedulers/scheduling_flow_match_euler_discrete.py +# Copyright 2025 Stability AI, Katherine Crowson and The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Flow Match Euler scheduler copied from Diffusers v0.39.0. + +Modified by vLLM-Omni contributors for local integration and RNG compatibility. +""" + +import math +from dataclasses import dataclass +from typing import Literal + +import numpy as np +import torch +from diffusers.configuration_utils import ConfigMixin, register_to_config +from diffusers.schedulers.scheduling_utils import SchedulerMixin +from diffusers.utils import BaseOutput, is_scipy_available, logging +from diffusers.utils.torch_utils import randn_tensor + +if is_scipy_available(): + import scipy.stats + +logger = logging.get_logger(__name__) # pylint: disable=invalid-name + + +@dataclass +class FlowMatchEulerDiscreteSchedulerOutput(BaseOutput): + """ + Output class for the scheduler's `step` function output. + + Args: + prev_sample (`torch.FloatTensor` of shape `(batch_size, num_channels, height, width)` for images): + Computed sample `(x_{t-1})` of previous timestep. `prev_sample` should be used as next model input in the + denoising loop. + """ + + prev_sample: torch.FloatTensor + + +class FlowMatchEulerDiscreteScheduler(SchedulerMixin, ConfigMixin): + """ + Euler scheduler. + + This model inherits from [`SchedulerMixin`] and [`ConfigMixin`]. Check the superclass documentation for the generic + methods the library implements for all schedulers such as loading and saving. + + Args: + num_train_timesteps (`int`, defaults to 1000): + The number of diffusion steps to train the model. + shift (`float`, defaults to 1.0): + The shift value for the timestep schedule. + use_dynamic_shifting (`bool`, defaults to False): + Whether to apply timestep shifting on-the-fly based on the image resolution. + base_shift (`float`, defaults to 0.5): + Value to stabilize image generation. Increasing `base_shift` reduces variation and image is more consistent + with desired output. + max_shift (`float`, defaults to 1.15): + Value change allowed to latent vectors. Increasing `max_shift` encourages more variation and image may be + more exaggerated or stylized. + base_image_seq_len (`int`, defaults to 256): + The base image sequence length. + max_image_seq_len (`int`, defaults to 4096): + The maximum image sequence length. + invert_sigmas (`bool`, defaults to False): + Whether to invert the sigmas. + shift_terminal (`float`, defaults to None): + The end value of the shifted timestep schedule. + use_karras_sigmas (`bool`, defaults to False): + Whether to use Karras sigmas for step sizes in the noise schedule during sampling. + use_exponential_sigmas (`bool`, defaults to False): + Whether to use exponential sigmas for step sizes in the noise schedule during sampling. + use_beta_sigmas (`bool`, defaults to False): + Whether to use beta sigmas for step sizes in the noise schedule during sampling. + time_shift_type (`str`, defaults to "exponential"): + The type of dynamic resolution-dependent timestep shifting to apply. Either "exponential" or "linear". + stochastic_sampling (`bool`, defaults to False): + Whether to use stochastic sampling. + """ + + _compatibles = [] + order = 1 + + @register_to_config + def __init__( + self, + num_train_timesteps: int = 1000, + shift: float = 1.0, + use_dynamic_shifting: bool = False, + base_shift: float | None = 0.5, + max_shift: float | None = 1.15, + base_image_seq_len: int = 256, + max_image_seq_len: int = 4096, + invert_sigmas: bool = False, + shift_terminal: float = None, + use_karras_sigmas: bool = False, + use_exponential_sigmas: bool = False, + use_beta_sigmas: bool = False, + time_shift_type: Literal["exponential", "linear"] = "exponential", + stochastic_sampling: bool = False, + ): + if self.config.use_beta_sigmas and not is_scipy_available(): + raise ImportError("Make sure to install scipy if you want to use beta sigmas.") + if ( + sum( + [ + self.config.use_beta_sigmas, + self.config.use_exponential_sigmas, + self.config.use_karras_sigmas, + ] + ) + > 1 + ): + raise ValueError( + "Only one of `config.use_beta_sigmas`, `config.use_exponential_sigmas`, " + "`config.use_karras_sigmas` can be used." + ) + if time_shift_type not in {"exponential", "linear"}: + raise ValueError("`time_shift_type` must either be 'exponential' or 'linear'.") + + timesteps = np.linspace(1, num_train_timesteps, num_train_timesteps, dtype=np.float32)[::-1].copy() + timesteps = torch.from_numpy(timesteps).to(dtype=torch.float32) + + sigmas = timesteps / num_train_timesteps + if not use_dynamic_shifting: + # when use_dynamic_shifting is True, we apply the timestep shifting on the fly based on the image resolution + sigmas = shift * sigmas / (1 + (shift - 1) * sigmas) + + self.timesteps = sigmas * num_train_timesteps + + self._step_index = None + self._begin_index = None + + self._shift = shift + + self.sigmas = sigmas.to("cpu") # to avoid too much CPU/GPU communication + self.sigma_min = self.sigmas[-1].item() + self.sigma_max = self.sigmas[0].item() + + @property + def shift(self): + """ + The value used for shifting. + """ + return self._shift + + @property + def step_index(self): + """ + The index counter for current timestep. It will increase 1 after each scheduler step. + """ + return self._step_index + + @property + def begin_index(self): + """ + The index for the first timestep. It should be set from pipeline with `set_begin_index` method. + """ + return self._begin_index + + # Copied from diffusers.schedulers.scheduling_dpmsolver_multistep.DPMSolverMultistepScheduler.set_begin_index + def set_begin_index(self, begin_index: int = 0): + """ + Sets the begin index for the scheduler. This function should be run from pipeline before the inference. + + Args: + begin_index (`int`, defaults to `0`): + The begin index for the scheduler. + """ + self._begin_index = begin_index + + def set_shift(self, shift: float): + """ + Sets the shift value for the scheduler. + + Args: + shift (`float`): + The shift value to be set. + """ + self._shift = shift + + def scale_noise( + self, + sample: torch.FloatTensor, + timestep: float | torch.FloatTensor, + noise: torch.FloatTensor | None = None, + ) -> torch.FloatTensor: + """ + Forward process in flow-matching + + Args: + sample (`torch.FloatTensor`): + The input sample. + timestep (`torch.FloatTensor`): + The current timestep in the diffusion chain. + noise (`torch.FloatTensor`): + The noise tensor. + + Returns: + `torch.FloatTensor`: + A scaled input sample. + """ + # Make sure sigmas and timesteps have the same device and dtype as original_samples + sigmas = self.sigmas.to(device=sample.device, dtype=sample.dtype) + + if sample.device.type == "mps" and torch.is_floating_point(timestep): + # mps does not support float64 + schedule_timesteps = self.timesteps.to(sample.device, dtype=torch.float32) + timestep = timestep.to(sample.device, dtype=torch.float32) + else: + schedule_timesteps = self.timesteps.to(sample.device) + timestep = timestep.to(sample.device) + + # self.begin_index is None when scheduler is used for training, or pipeline does not implement set_begin_index + if self.begin_index is None: + step_indices = [self.index_for_timestep(t, schedule_timesteps) for t in timestep] + elif self.step_index is not None: + # add_noise is called after first denoising step (for inpainting) + step_indices = [self.step_index] * timestep.shape[0] + else: + # add noise is called before first denoising step to create initial latent(img2img) + step_indices = [self.begin_index] * timestep.shape[0] + + sigma = sigmas[step_indices].flatten() + while len(sigma.shape) < len(sample.shape): + sigma = sigma.unsqueeze(-1) + + sample = sigma * noise + (1.0 - sigma) * sample + + return sample + + def _sigma_to_t(self, sigma) -> float: + return sigma * self.config.num_train_timesteps + + def time_shift(self, mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor: + """ + Apply time shifting to the sigmas. + + Args: + mu (`float`): + The mu parameter for the time shift. + sigma (`float`): + The sigma parameter for the time shift. + t (`torch.Tensor`): + The input timesteps. + + Returns: + `torch.Tensor`: + The time-shifted timesteps. + """ + if self.config.time_shift_type == "exponential": + return self._time_shift_exponential(mu, sigma, t) + elif self.config.time_shift_type == "linear": + return self._time_shift_linear(mu, sigma, t) + + def stretch_shift_to_terminal(self, t: torch.Tensor) -> torch.Tensor: + r""" + Stretches and shifts the timestep schedule to ensure it terminates at the configured `shift_terminal` config + value. + + Reference: + https://github.com/Lightricks/LTX-Video/blob/a01a171f8fe3d99dce2728d60a73fecf4d4238ae/ltx_video/schedulers/rf.py#L51 + + Args: + t (`torch.Tensor`): + A tensor of timesteps to be stretched and shifted. + + Returns: + `torch.Tensor`: + A tensor of adjusted timesteps such that the final value equals `self.config.shift_terminal`. + """ + one_minus_z = 1 - t + scale_factor = one_minus_z[-1] / (1 - self.config.shift_terminal) + stretched_t = 1 - (one_minus_z / scale_factor) + return stretched_t + + def set_timesteps( + self, + num_inference_steps: int | None = None, + device: str | torch.device = None, + sigmas: list[float] | None = None, + mu: float | None = None, + timesteps: list[float] | None = None, + ): + """ + Sets the discrete timesteps used for the diffusion chain (to be run before inference). + + Args: + num_inference_steps (`int`, *optional*): + The number of diffusion steps used when generating samples with a pre-trained model. + device (`str` or `torch.device`, *optional*): + The device to which the timesteps should be moved to. If `None`, the timesteps are not moved. + sigmas (`list[float]`, *optional*): + Custom values for sigmas to be used for each diffusion step. If `None`, the sigmas are computed + automatically. + mu (`float`, *optional*): + Determines the amount of shifting applied to sigmas when performing resolution-dependent timestep + shifting. + timesteps (`list[float]`, *optional*): + Custom values for timesteps to be used for each diffusion step. If `None`, the timesteps are computed + automatically. + """ + if self.config.use_dynamic_shifting and mu is None: + raise ValueError("`mu` must be passed when `use_dynamic_shifting` is set to be `True`") + + if sigmas is not None and timesteps is not None: + if len(sigmas) != len(timesteps): + raise ValueError("`sigmas` and `timesteps` should have the same length") + + if num_inference_steps is not None: + if (sigmas is not None and len(sigmas) != num_inference_steps) or ( + timesteps is not None and len(timesteps) != num_inference_steps + ): + raise ValueError( + "`sigmas` and `timesteps` should have the same length as num_inference_steps, " + "if `num_inference_steps` is provided" + ) + else: + num_inference_steps = len(sigmas) if sigmas is not None else len(timesteps) + + self.num_inference_steps = num_inference_steps + + # 1. Prepare default sigmas + is_timesteps_provided = timesteps is not None + + if is_timesteps_provided: + timesteps = np.array(timesteps).astype(np.float32) + + if sigmas is None: + if timesteps is None: + timesteps = np.linspace( + self._sigma_to_t(self.sigma_max), + self._sigma_to_t(self.sigma_min), + num_inference_steps, + ) + sigmas = timesteps / self.config.num_train_timesteps + else: + sigmas = np.array(sigmas).astype(np.float32) + num_inference_steps = len(sigmas) + + # 2. Perform timestep shifting. Either no shifting is applied, or resolution-dependent shifting of + # "exponential" or "linear" type is applied + if self.config.use_dynamic_shifting: + sigmas = self.time_shift(mu, 1.0, sigmas) + else: + sigmas = self.shift * sigmas / (1 + (self.shift - 1) * sigmas) + + # 3. If required, stretch the sigmas schedule to terminate at the configured `shift_terminal` value + if self.config.shift_terminal: + sigmas = self.stretch_shift_to_terminal(sigmas) + + # 4. If required, convert sigmas to one of karras, exponential, or beta sigma schedules + if self.config.use_karras_sigmas: + sigmas = self._convert_to_karras(in_sigmas=sigmas, num_inference_steps=num_inference_steps) + elif self.config.use_exponential_sigmas: + sigmas = self._convert_to_exponential(in_sigmas=sigmas, num_inference_steps=num_inference_steps) + elif self.config.use_beta_sigmas: + sigmas = self._convert_to_beta(in_sigmas=sigmas, num_inference_steps=num_inference_steps) + + # 5. Convert sigmas and timesteps to tensors and move to specified device + sigmas = torch.from_numpy(sigmas).to(dtype=torch.float32, device=device) + if not is_timesteps_provided: + timesteps = sigmas * self.config.num_train_timesteps + else: + timesteps = torch.from_numpy(timesteps).to(dtype=torch.float32, device=device) + + # 6. Append the terminal sigma value. + # If a model requires inverted sigma schedule for denoising but timesteps without inversion, the + # `invert_sigmas` flag can be set to `True`. This case is only required in Mochi + if self.config.invert_sigmas: + sigmas = 1.0 - sigmas + timesteps = sigmas * self.config.num_train_timesteps + sigmas = torch.cat([sigmas, torch.ones(1, device=sigmas.device)]) + else: + sigmas = torch.cat([sigmas, torch.zeros(1, device=sigmas.device)]) + + self.timesteps = timesteps + self.sigmas = sigmas + self._step_index = None + self._begin_index = None + + def index_for_timestep( + self, + timestep: float | torch.FloatTensor, + schedule_timesteps: torch.FloatTensor | None = None, + ) -> int: + """ + Get the index for the given timestep. + + Args: + timestep (`float` or `torch.FloatTensor`): + The timestep to find the index for. + schedule_timesteps (`torch.FloatTensor`, *optional*): + The schedule timesteps to validate against. If `None`, the scheduler's timesteps are used. + + Returns: + `int`: + The index of the timestep. + """ + if schedule_timesteps is None: + schedule_timesteps = self.timesteps + + indices = (schedule_timesteps == timestep).nonzero() + + # The sigma index that is taken for the **very** first `step` + # is always the second index (or the last index if there is only 1) + # This way we can ensure we don't accidentally skip a sigma in + # case we start in the middle of the denoising schedule (e.g. for image-to-image) + pos = 1 if len(indices) > 1 else 0 + + return indices[pos].item() + + def _init_step_index(self, timestep: float | torch.FloatTensor) -> None: + if self.begin_index is None: + if isinstance(timestep, torch.Tensor): + timestep = timestep.to(self.timesteps.device) + self._step_index = self.index_for_timestep(timestep) + else: + self._step_index = self._begin_index + + def step( + self, + model_output: torch.FloatTensor, + timestep: float | torch.FloatTensor, + sample: torch.FloatTensor, + s_churn: float = 0.0, + s_tmin: float = 0.0, + s_tmax: float = float("inf"), + s_noise: float = 1.0, + generator: torch.Generator | None = None, + per_token_timesteps: torch.Tensor | None = None, + return_dict: bool = True, + ) -> FlowMatchEulerDiscreteSchedulerOutput | tuple: + """ + Predict the sample from the previous timestep by reversing the SDE. This function propagates the diffusion + process from the learned model outputs (most often the predicted noise). + + Args: + model_output (`torch.FloatTensor`): + The direct output from learned diffusion model. + timestep (`float`): + The current discrete timestep in the diffusion chain. + sample (`torch.FloatTensor`): + A current instance of a sample created by the diffusion process. + s_churn (`float`): + s_tmin (`float`): + s_tmax (`float`): + s_noise (`float`, defaults to 1.0): + Scaling factor for noise added to the sample. + generator (`torch.Generator`, *optional*): + A random number generator. + per_token_timesteps (`torch.Tensor`, *optional*): + The timesteps for each token in the sample. + return_dict (`bool`, defaults to `True`): + Whether or not to return a + [`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchEulerDiscreteSchedulerOutput`] or tuple. + + Returns: + [`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchEulerDiscreteSchedulerOutput`] or `tuple`: + If return_dict is `True`, + [`~schedulers.scheduling_flow_match_euler_discrete.FlowMatchEulerDiscreteSchedulerOutput`] is returned, + otherwise a tuple is returned where the first element is the sample tensor. + """ + + if isinstance(timestep, int) or isinstance(timestep, torch.IntTensor) or isinstance(timestep, torch.LongTensor): + raise ValueError( + ( + "Passing integer indices (e.g. from `enumerate(timesteps)`) as timesteps to" + " `FlowMatchEulerDiscreteScheduler.step()` is not supported. Make sure to pass" + " one of the `scheduler.timesteps` as a timestep." + ), + ) + + if self.step_index is None: + self._init_step_index(timestep) + + # Upcast to avoid precision issues when computing prev_sample + sample = sample.to(torch.float32) + + if per_token_timesteps is not None: + per_token_sigmas = per_token_timesteps / self.config.num_train_timesteps + + sigmas = self.sigmas[:, None, None] + lower_mask = sigmas < per_token_sigmas[None] - 1e-6 + lower_sigmas = lower_mask * sigmas + lower_sigmas, _ = lower_sigmas.max(dim=0) + + current_sigma = per_token_sigmas[..., None] + next_sigma = lower_sigmas[..., None] + dt = current_sigma - next_sigma + else: + sigma_idx = self.step_index + sigma = self.sigmas[sigma_idx] + sigma_next = self.sigmas[sigma_idx + 1] + + current_sigma = sigma + next_sigma = sigma_next + dt = sigma_next - sigma + + if self.config.stochastic_sampling: + x0 = sample - current_sigma * model_output + noise = randn_tensor(sample.shape, generator=generator, device=sample.device, dtype=sample.dtype) + prev_sample = (1.0 - next_sigma) * x0 + next_sigma * noise + else: + prev_sample = sample + dt * model_output + + # upon completion increase step index by one + self._step_index += 1 + if per_token_timesteps is None: + # Cast sample back to model compatible dtype + prev_sample = prev_sample.to(model_output.dtype) + + if not return_dict: + return (prev_sample,) + + return FlowMatchEulerDiscreteSchedulerOutput(prev_sample=prev_sample) + + # Copied from diffusers.schedulers.scheduling_euler_discrete.EulerDiscreteScheduler._convert_to_karras + def _convert_to_karras(self, in_sigmas: torch.Tensor, num_inference_steps: int) -> torch.Tensor: + """ + Construct the noise schedule as proposed in [Elucidating the Design Space of Diffusion-Based Generative + Models](https://huggingface.co/papers/2206.00364). + + Args: + in_sigmas (`torch.Tensor`): + The input sigma values to be converted. + num_inference_steps (`int`): + The number of inference steps to generate the noise schedule for. + + Returns: + `torch.Tensor`: + The converted sigma values following the Karras noise schedule. + """ + + # Hack to make sure that other schedulers which copy this function don't break + # TODO: Add this logic to the other schedulers + if hasattr(self.config, "sigma_min"): + sigma_min = self.config.sigma_min + else: + sigma_min = None + + if hasattr(self.config, "sigma_max"): + sigma_max = self.config.sigma_max + else: + sigma_max = None + + sigma_min = sigma_min if sigma_min is not None else in_sigmas[-1].item() + sigma_max = sigma_max if sigma_max is not None else in_sigmas[0].item() + + rho = 7.0 # 7.0 is the value used in the paper + ramp = np.linspace(0, 1, num_inference_steps) + min_inv_rho = sigma_min ** (1 / rho) + max_inv_rho = sigma_max ** (1 / rho) + sigmas = (max_inv_rho + ramp * (min_inv_rho - max_inv_rho)) ** rho + return sigmas + + # Copied from diffusers.schedulers.scheduling_euler_discrete.EulerDiscreteScheduler._convert_to_exponential + def _convert_to_exponential(self, in_sigmas: torch.Tensor, num_inference_steps: int) -> torch.Tensor: + """ + Construct an exponential noise schedule. + + Args: + in_sigmas (`torch.Tensor`): + The input sigma values to be converted. + num_inference_steps (`int`): + The number of inference steps to generate the noise schedule for. + + Returns: + `torch.Tensor`: + The converted sigma values following an exponential schedule. + """ + + # Hack to make sure that other schedulers which copy this function don't break + # TODO: Add this logic to the other schedulers + if hasattr(self.config, "sigma_min"): + sigma_min = self.config.sigma_min + else: + sigma_min = None + + if hasattr(self.config, "sigma_max"): + sigma_max = self.config.sigma_max + else: + sigma_max = None + + sigma_min = sigma_min if sigma_min is not None else in_sigmas[-1].item() + sigma_max = sigma_max if sigma_max is not None else in_sigmas[0].item() + + sigmas = np.exp(np.linspace(math.log(sigma_max), math.log(sigma_min), num_inference_steps)) + return sigmas + + # Copied from diffusers.schedulers.scheduling_euler_discrete.EulerDiscreteScheduler._convert_to_beta + def _convert_to_beta( + self, in_sigmas: torch.Tensor, num_inference_steps: int, alpha: float = 0.6, beta: float = 0.6 + ) -> torch.Tensor: + """ + Construct a beta noise schedule as proposed in [Beta Sampling is All You + Need](https://huggingface.co/papers/2407.12173). + + Args: + in_sigmas (`torch.Tensor`): + The input sigma values to be converted. + num_inference_steps (`int`): + The number of inference steps to generate the noise schedule for. + alpha (`float`, *optional*, defaults to `0.6`): + The alpha parameter for the beta distribution. + beta (`float`, *optional*, defaults to `0.6`): + The beta parameter for the beta distribution. + + Returns: + `torch.Tensor`: + The converted sigma values following a beta distribution schedule. + """ + + # Hack to make sure that other schedulers which copy this function don't break + # TODO: Add this logic to the other schedulers + if hasattr(self.config, "sigma_min"): + sigma_min = self.config.sigma_min + else: + sigma_min = None + + if hasattr(self.config, "sigma_max"): + sigma_max = self.config.sigma_max + else: + sigma_max = None + + sigma_min = sigma_min if sigma_min is not None else in_sigmas[-1].item() + sigma_max = sigma_max if sigma_max is not None else in_sigmas[0].item() + + sigmas = np.array( + [ + sigma_min + (ppf * (sigma_max - sigma_min)) + for ppf in [ + scipy.stats.beta.ppf(timestep, alpha, beta) + for timestep in 1 - np.linspace(0, 1, num_inference_steps) + ] + ] + ) + return sigmas + + def _time_shift_exponential(self, mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor: + return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma) + + def _time_shift_linear(self, mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor: + return mu / (mu + (1 / t - 1) ** sigma) + + def __len__(self) -> int: + return self.config.num_train_timesteps