Compare commits

..
Author SHA1 Message Date
Hongsheng Liu 089851e55a Merge origin/main into codex-vae-spatial-decode-auto
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-13 23:49:11 +08:00
Hongsheng Liu 3466ff9b98 docs: standardize VAE Parallel naming
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 23:06:06 +08:00
Hongsheng Liu e16473c6b5 docs: align VAE parallel file convention
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 22:24:58 +08:00
Hongsheng Liu 14d67bcd60 docs: align VAE parallelism file names
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 22:15:51 +08:00
Hongsheng Liu 5234e3599c docs: generalize VAE parallelism design
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 22:07:54 +08:00
Hongsheng Liu 3aefb31727 docs(diffusion): remove VAE policy attribution
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 12:01:32 +08:00
Hongsheng Liu c8210b8b5c fix(diffusion): address Wan auto decode review feedback
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-12 11:49:47 +08:00
Hongsheng Liu 879d19c4c2 Merge branch 'main' into codex-vae-spatial-decode-auto 2026-08-12 11:38:12 +08:00
Hongsheng Liu af1825e07d feat(diffusion): select Wan VAE decode mode automatically
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-11 17:36:35 +08:00
73 changed files with 1080 additions and 8481 deletions

No files matched your search

+2 -8
View File
@@ -456,7 +456,7 @@ steps:
timeout_in_minutes: 90 timeout_in_minutes: 90
commands: commands:
- pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model" - pytest -s -v tests/e2e/offline_inference/test_wan22_autoround_w4a16_expansion.py -m "full_model and cuda" --run-level "full_model"
- pytest -s -v tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_ltx.py -m "full_model and cuda" --run-level "full_model" - pytest -s -v tests/e2e/online_serving/test_wan_2_1_vace_expansion.py tests/e2e/online_serving/test_ltx2_expansion.py -m "full_model and cuda" --run-level "full_model"
mirror_hardwares: h100_2 mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test" - label: ":full_moon: Diffusion X2V · Wan2.2 Accuracy Test"
@@ -477,16 +477,10 @@ steps:
commands: commands:
- pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m full_model --run-level full_model - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_t2v/test_hunyuanvideo15_t2v_video_similarity.py -m full_model --run-level full_model
- pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model - pytest -s -v tests/e2e/accuracy/hunyuanvideo15_i2v/test_hunyuanvideo15_i2v_video_similarity.py -m full_model --run-level full_model
- pytest -s -v 'tests/e2e/accuracy/ltx/test_ltx_official_similarity.py::test_ltx_one_stage_matches_official[ltx2_3_i2v]' -m full_model --run-level full_model
- pytest -s -v tests/e2e/accuracy/test_video_streaming_output_similarity.py -m full_model --run-level full_model - pytest -s -v tests/e2e/accuracy/test_video_streaming_output_similarity.py -m full_model --run-level full_model
mirror_hardwares: h100_2 mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · LTX Accuracy Test"
timeout_in_minutes: 180
commands:
- pytest -s -v 'tests/e2e/accuracy/ltx/test_ltx_official_similarity.py::test_ltx_matches_official[ltx2_3_i2v]' -m full_model --run-level full_model
- pytest -s -v 'tests/e2e/accuracy/ltx/test_ltx_official_similarity.py::test_ltx_matches_official[ltx23_two_stage_layer_fused_i2v]' -m full_model --run-level full_model
mirror_hardwares: h100_1
- label: ":full_moon: Diffusion X2V · Perf Test" - label: ":full_moon: Diffusion X2V · Perf Test"
key: nightly-diffusion-x2v-performance key: nightly-diffusion-x2v-performance
timeout_in_minutes: 180 timeout_in_minutes: 180
-1
View File
@@ -16,7 +16,6 @@ Easy, fast, and cheap omni-modality model serving for everyone
--- ---
*Latest News* 🔥 *Latest News* 🔥
- [2026/08] [VeRL-Omni](https://github.com/verl-project/verl-omni) `v0.2.0` is released: faster diffusion RL powered by vLLM-Omni (request-level/step-wise batching with FA3), rebuilt Qwen3-Omni multimodal training (DPO & GSPO), plus LTX-2.3, Qwen-Image-Edit support and more. See the [release notes](https://github.com/verl-project/verl-omni/releases/tag/v0.2.0).
- [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support. - [2026/08] We released [0.26.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.26.0) - aligned with the vLLM 0.26 release line, featuring [MiniMax H3](recipes/MiniMaxAI/MiniMax-H3.md) joint video/audio generation, an experimental full-duplex realtime runtime for [MiniCPM-o 4.5](recipes/OpenBMB/MiniCPM-o-4_5.md), distributed layerwise diffusion offload, and broader model, hardware, streaming, TTS, and quantization support.
- [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/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] Starting with [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0), vLLM-Omni publishes a stable release aligned with every even-numbered upstream vLLM minor version. [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0), [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0), [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0), and [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) continued this cadence, expanding omni and world-model support with [NVIDIA Cosmos3](recipes/cosmos3/Cosmos3-Nano.md) and DreamZero, adding models such as MiniCPM-o 4.5, MOSS-TTS, and Lance, and advancing TTS, diffusion, distributed execution, quantization, RL integration through [VeRL-Omni](https://github.com/verl-project/verl-omni), and CUDA/ROCm/MUSA/NPU/XPU coverage. - [2026/06] Starting with [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0), vLLM-Omni publishes a stable release aligned with every even-numbered upstream vLLM minor version. [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0), [0.18.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.18.0), [0.20.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.20.0), and [0.22.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.22.0) continued this cadence, expanding omni and world-model support with [NVIDIA Cosmos3](recipes/cosmos3/Cosmos3-Nano.md) and DreamZero, adding models such as MiniCPM-o 4.5, MOSS-TTS, and Lance, and advancing TTS, diffusion, distributed execution, quantization, RL integration through [VeRL-Omni](https://github.com/verl-project/verl-omni), and CUDA/ROCm/MUSA/NPU/XPU coverage.
+2 -2
View File
@@ -71,7 +71,7 @@ nav:
- Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md - Hybrid Sharded Data Parallel: user_guide/diffusion/parallelism/hsdp.md
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md - Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md - Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.md
- VAE Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md - VAE Parallel: user_guide/diffusion/parallelism/vae_parallel.md
- Attention Backends: user_guide/diffusion/attention_backends.md - Attention Backends: user_guide/diffusion/attention_backends.md
- Regional Compilation: user_guide/diffusion/regional_compilation.md - Regional Compilation: user_guide/diffusion/regional_compilation.md
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md - Frame Interpolation: user_guide/diffusion/frame_interpolation.md
@@ -123,7 +123,7 @@ nav:
- Pipeline Parallel: design/feature/pipeline_parallel.md - Pipeline Parallel: design/feature/pipeline_parallel.md
- Sequence Parallel: design/feature/sequence_parallel.md - Sequence Parallel: design/feature/sequence_parallel.md
- Tensor Parallel: design/feature/tensor_parallel.md - Tensor Parallel: design/feature/tensor_parallel.md
- VAE Patch Parallelism: design/feature/vae_parallel.md - VAE Parallel: design/feature/vae_parallel.md
- Attention Optimization: - Attention Optimization:
- Skip-Softmax: design/feature/skip_softmax.md - Skip-Softmax: design/feature/skip_softmax.md
- Cache-DiT: design/feature/cache_dit.md - Cache-DiT: design/feature/cache_dit.md
+1 -1
View File
@@ -398,7 +398,7 @@ the source for configuration, compatibility, and implementation details.
* **Composable parallelism:** Diffusion stages can combine [CFG-Parallel](feature/cfg_parallel.md), * **Composable parallelism:** Diffusion stages can combine [CFG-Parallel](feature/cfg_parallel.md),
[Expert Parallel](feature/expert_parallel.md), [HSDP](feature/hsdp.md), [Expert Parallel](feature/expert_parallel.md), [HSDP](feature/hsdp.md),
[Pipeline Parallel](feature/pipeline_parallel.md), [Sequence Parallel](feature/sequence_parallel.md), [Pipeline Parallel](feature/pipeline_parallel.md), [Sequence Parallel](feature/sequence_parallel.md),
[Tensor Parallel](feature/tensor_parallel.md), and [VAE Patch Parallelism](feature/vae_parallel.md) [Tensor Parallel](feature/tensor_parallel.md), and [VAE Parallel](feature/vae_parallel.md)
according to the pipeline and hardware topology. according to the pipeline and hardware topology.
* **Attention and cache acceleration:** [Skip-Softmax](feature/skip_softmax.md), * **Attention and cache acceleration:** [Skip-Softmax](feature/skip_softmax.md),
[Cache-DiT](feature/cache_dit.md), and [TeaCache](feature/teacache.md) [Cache-DiT](feature/cache_dit.md), and [TeaCache](feature/teacache.md)
+143 -38
View File
@@ -1,42 +1,57 @@
# VAE Patch Parallelism # VAE Parallel
This document describes how to add **VAE Patch Parallelism** support to a diffusion model. This document defines the VAE parallelism strategy contract and describes how to
We use **Qwen-Image** as the reference implementation for decode parallel, and **Wan2.2** for encode parallel. add a strategy to a diffusion model. Tile/patch parallelism is the portable
baseline. Spatial-shard parallelism is a model-specific decode strategy that can
be selected explicitly or through a request-aware policy.
We use **Qwen-Image** as the reference implementation for tile-parallel decode,
**Wan2.2** for tile-parallel encode, and **Wan2.2** as the first spatial-shard
decode implementation. Other VAE families can add spatial strategies later
without changing the public mode meanings defined here.
--- ---
## Table of Contents ## Table of Contents
- [Overview](#overview) - [Overview](#overview)
- [Step-by-Step Implementation (Decode)](#step-by-step-implementation-decode) - [Strategy Selection Contract](#strategy-selection-contract)
- [Encode Parallel Implementation](#encode-parallel-implementation) - [Tile/Patch Implementation (Decode)](#tilepatch-implementation-decode)
- [Tile/Patch Encode Implementation](#tilepatch-encode-implementation)
- [Testing](#testing) - [Testing](#testing)
- [Reference Implementations](#reference-implementations) - [Reference Implementations](#reference-implementations)
- [Spatially-Sharded Decode](#spatially-sharded-decode)
- [Adding Another VAE Strategy](#adding-another-vae-strategy)
- [Summary](#summary) - [Summary](#summary)
--- ---
## Overview ## Overview
### What is Vae Patch parallel? ### What is VAE parallelism?
**VAE Patch Parallelism** is an acceleration technique for both **encoding** and **decoding**. Instead of processing the entire tensor at once, the tensor is: VAE parallelism distributes VAE **encoding** or **decoding** across multiple
ranks. The implementation currently has two strategy families:
+ Split into multiple spatial tiles | Strategy | Unit of work | Communication | Current scope |
|----------|--------------|---------------|---------------|
| Tile/patch | Independent overlapping tiles | Gather, stitch, and optionally broadcast | Encode and decode across supported distributed VAEs |
| Spatial shard | One feature map sharded along a spatial axis | Per-layer halo exchange followed by output gather | Wan decode |
+ Distributed across multiple ranks Both strategies preserve the model-specific receptive field and reconstruct the
same logical output as single-rank execution. They differ in how work is divided:
tile parallelism assigns complete overlapping tiles to ranks, while spatial
sharding keeps one logical feature map partitioned throughout the decoder.
+ Encoded/Decoded in parallel This approach can:
+ Merged to reconstruct the final output + Distribute computation across multiple devices
+ Reduce peak memory usage per device
+ Accelerate encoding or decoding latency
This approach: The actual benefit depends on shape, topology, communication cost, and VAE
architecture. A strategy must not become the global default without parity and
+ Distributes computation across multiple devices performance evidence on its supported models.
+ Reduces peak memory usage per device
+ Accelerates encoding/decoding latency
### When to Use Encode vs Decode Parallel ### When to Use Encode vs Decode Parallel
@@ -45,8 +60,16 @@ This approach:
| **Decode Parallel** | Text-to-Image, Text-to-Video | Latent → Image/Video | | **Decode Parallel** | Text-to-Image, Text-to-Video | Latent → Image/Video |
| **Encode Parallel** | Image-to-Video (I2V) | Image → Latent (for conditioning) | | **Encode Parallel** | Image-to-Video (I2V) | Image → Latent (for conditioning) |
### Architecture ### Architecture and ownership
We introduce **DistributedVaeExecutor** as the core component responsible for distributed VAE encoding/decoding.
`DiffusionParallelConfig.vae_parallel_mode` carries the public strategy request.
The shared registry forwards that value to the VAE, but each distributed VAE
adapter owns its supported modes, request-shape selection, topology validation,
and fallback behavior. Model-specific policy must not be added to the shared
runner or registry.
**DistributedVaeExecutor** is the model-agnostic core for tile-parallel VAE
encoding and decoding.
The executor is model-agnostic and accepts three function parameters: The executor is model-agnostic and accepts three function parameters:
@@ -76,6 +99,11 @@ This design separates:
+ Model-specific tiling and merging logic + Model-specific tiling and merging logic
Spatial-shard implementations use the same configured VAE process group but do
not use the tile task executor. They keep the feature map sharded across decoder
layers and own their halo-exchange and final-gather contracts inside the model's
distributed autoencoder implementation.
#### Why split / exec / merge is necessary? #### Why split / exec / merge is necessary?
The latent tensor cannot be arbitrarily partitioned. The latent tensor cannot be arbitrarily partitioned.
@@ -92,7 +120,34 @@ Therefore:
+ Merge must perform blending to avoid seams + Merge must perform blending to avoid seams
## Step-by-Step Implementation (Decode) ## Strategy Selection Contract
`vae_parallel_mode="tile"` is the compatibility-preserving global default.
Other values are stable strategy requests, but support remains model-specific.
| Mode | Meaning | Current implementation | Unsupported or ineligible request |
|------|---------|------------------------|-----------------------------------|
| `tile` | Use the existing tile/patch executor | All distributed VAE adapters | Not applicable |
| `auto` | Let the VAE adapter select a validated strategy for each request | Wan decode selects height or width spatial sharding | Wan uses tile when ineligible; unsupported VAE families reject or document their fallback |
| `spatial_shard_height` | Force spatial-shard decode along height | Wan decode | Wan warns and uses tile when ineligible; unsupported VAE families reject |
| `spatial_shard_width` | Force spatial-shard decode along width | Wan decode | Wan warns and uses tile when ineligible; unsupported VAE families reject |
The shared configuration layer validates the mode name. It does not decide
whether a model supports that mode. A VAE adapter adding a mode must:
1. Keep `tile` as an exact feature-off path.
2. Select a strategy per request rather than from stale process-global state.
3. Validate input rank/shape and process-group membership before collectives.
4. Reject or explicitly fall back from unsupported combinations; never silently
reinterpret one forced strategy as another.
5. Preserve direct encode/decode behavior when Diffusers does not select a tiled
path.
`auto` is a policy hook, not a universal heuristic. Each VAE family may select
from only the strategies it has validated. Adding another family must not change
Wan's selector or the meanings of the explicit modes.
## Tile/Patch Implementation (Decode)
### Step 1: Implement DistributedAutoencoderKLQwenImage ### Step 1: Implement DistributedAutoencoderKLQwenImage
`QwenImagePipeline` use `AutoencoderKLQwenImage` for vae, so implement a distributed version: `QwenImagePipeline` use `AutoencoderKLQwenImage` for vae, so implement a distributed version:
@@ -251,7 +306,7 @@ class YourModelPipeline(nn.Module):
+ ).to(self.device) + ).to(self.device)
``` ```
## Encode Parallel Implementation ## Tile/Patch Encode Implementation
For models that require VAE encoding (e.g., Image-to-Video), you can also parallelize the encode operation. We use **Wan2.2** as the reference implementation. For models that require VAE encoding (e.g., Image-to-Video), you can also parallelize the encode operation. We use **Wan2.2** as the reference implementation.
@@ -449,18 +504,24 @@ Complete examples in the codebase:
--- ---
## Spatially-Sharded Decode (Wan) ## Spatially-Sharded Decode
The tile-parallel executor above assigns independent spatial **tiles** to ranks. The Wan VAE additionally supports a **spatially-sharded decode** backend, selected through `DiffusionParallelConfig.vae_parallel_mode` (`"spatial_shard_height"` or `"spatial_shard_width"`; default is `"tile"`). The tile-parallel executor above assigns independent spatial **tiles** to ranks.
A spatial-shard backend instead partitions one global decoder feature map along
height or width. Wan is the first supported implementation.
`DiffusionParallelConfig.vae_parallel_mode="tile"` remains the default. For Wan,
opt into request-shape selection with `"auto"`, or force an axis with
`"spatial_shard_height"` or `"spatial_shard_width"`.
### How it differs from tile parallel ### How it differs from tile parallel
| Aspect | Tile parallel (`"tile"`) | Spatially-sharded (`"spatial_shard_height"`/`"spatial_shard_width"`) | | Aspect | Tile parallel (`"tile"`) | Automatic (`"auto"`) | Spatially-sharded (`"spatial_shard_height"`/`"spatial_shard_width"`) |
|--------|--------------------------|-----------------------------------------------| |--------|--------------------------|----------------------|-----------------------------------------------|
| Unit of work | Independent overlapping tiles | A single global feature map sharded along H or W | | Unit of work | Independent overlapping tiles | Chooses tile or a spatial shard per request | A single global feature map sharded along H or W |
| Cross-rank communication | Gather tiles to rank 0, stitch + blend | Per-conv **halo exchange** of boundary rows/cols (P2P) | | Cross-rank communication | Gather tiles to rank 0, stitch + blend | Depends on the selected path | Per-conv **halo exchange** of boundary rows/cols (P2P) |
| Output assembly | Blend overlapping tiles | All-gather shards on rank 0, trim padding (matches `broadcast_result=False`) | | Output assembly | Blend overlapping tiles | Depends on the selected path | All-gather shards on rank 0, trim padding (matches `broadcast_result=False`) |
| Scope | Decode + encode | Decode only | | Scope | Decode + encode | Wan decode selection; encode remains tiled | Wan decode only |
Spatial-shard decode swaps the decoder's spatial convolutions/padding for halo-exchanging variants (`WanDistConv2d`, `WanDistCausalConv3d`, `WanDistZeroPad2d`) so each rank only holds a shard of the activations but still sees the correct receptive field at shard boundaries. Implementation lives in `vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py`. Spatial-shard decode swaps the decoder's spatial convolutions/padding for halo-exchanging variants (`WanDistConv2d`, `WanDistCausalConv3d`, `WanDistZeroPad2d`) so each rank only holds a shard of the activations but still sees the correct receptive field at shard boundaries. Implementation lives in `vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py`.
@@ -476,21 +537,65 @@ serve.py (--vae-parallel-mode) / OmniEngineArgs
-> DistributedAutoencoderKLWan.tiled_decode dispatch -> DistributedAutoencoderKLWan.tiled_decode dispatch
``` ```
`DistributedAutoencoderKLWan._spatial_shard_decode_enabled()` gates the path: it requires distributed decode to be enabled, a 5D latent, and `vae_patch_parallel_size == DiT group size`. Otherwise it logs a warning and falls back to tile-parallel decode. `DistributedAutoencoderKLWan._spatial_shard_decode_enabled()` gates the path: it
requires distributed decode to be enabled, a 5D latent, and
`vae_patch_parallel_size == DiT group size`. The method is reached only after
Diffusers has selected tiled decode for the request. In `auto` mode the longer
axis that can cover every rank is selected, with width winning ties. If neither
axis can cover the group, auto mode uses tile decode. This policy is cached only
through normal kernel shape caches; it is reevaluated for every request.
### Notes ### Notes
- The decoder is patched **in place** the first time spatial-shard decode runs and is bound to a single split dimension for the lifetime of the VAE instance. - The decoder is patched **in place** the first time spatial-shard decode runs. Its wrappers consult a context-local split dimension and retain an exact direct fallback, allowing later requests to select tile, height, or width safely.
- Numerical correctness vs. single-GPU decode is covered by `tests/diffusion/distributed/test_wan_spatial_shard.py::test_spatial_shard_decode_matches_reference` (multi-GPU, nightly `full_model` + `distributed_cuda`). - Numerical correctness vs. single-GPU decode is covered by `tests/diffusion/distributed/test_wan_spatial_shard.py::test_spatial_shard_decode_matches_reference` (multi-GPU, nightly `full_model` + `distributed_cuda`).
## Adding Another VAE Strategy
Add future strategies through the distributed autoencoder that owns the model
semantics. Do not add model-name dispatch to the shared runner, registry, or
`DistributedVaeExecutor`.
For a new VAE family:
1. **Declare capability**: document which modes, operations, shapes, dtypes, and
process-group layouts the adapter supports.
2. **Implement model-local selection**: keep `auto` policy and explicit-mode
dispatch in the distributed autoencoder. The selector may use request shape,
but must be deterministic and side-effect free.
3. **Preserve the baseline**: retain exact tile/direct fallbacks and state-dict
compatibility. Structural wrappers must not register duplicate parameters or
alter checkpoint names.
4. **Define communication**: derive shards from the configured VAE group, keep
collectives symmetric, specify padding/halo ownership, and return the same
output placement as the tile path.
5. **Bound request state**: make the selected axis and scratch buffers
request-scoped, release temporary buffers on success and failure, and support
alternating modes in one long-lived worker.
6. **Validate independently**: compare each explicit strategy against the
single-rank reference, then test `auto`, tile fallback, unsupported topology,
and a mixed request sequence. Record per-rank peak memory and decode latency
before documenting a preferred mode.
Promote a selector into shared infrastructure only after at least two VAE
families use the same inputs, invariants, fallback behavior, and lifecycle. Until
then, shared code owns transport and configuration while each model adapter owns
policy.
--- ---
## Summary ## Summary
Adding VAE Patch Parallel support to diffusion model: Adding VAE parallel support to a diffusion model:
1. **Implement Distributed VAE** - Inherit from base VAE class and `DistributedVaeMixin` 1. **Implement the distributed VAE** - Inherit from the base VAE class and
2. **Decode Parallel** - Refactor `tiled_decode` into `tile_split`/`tile_exec`/`tile_merge` `DistributedVaeMixin`.
3. **Encode Parallel** (optional) - Implement `encode_tile_split`/`encode_tile_exec`/`encode_tile_merge` for I2V models 2. **Keep tile as the baseline** - For tile decode, refactor `tiled_decode` into
4. **Change VAE model in pipeline** - Use the distributed version `tile_split`/`tile_exec`/`tile_merge`.
5. **Test** - Verify numerical consistency with `vae_patch_parallel_size=1` vs `N` 3. **Add encode parallelism when needed** - Implement
`encode_tile_split`/`encode_tile_exec`/`encode_tile_merge` for I2V models.
4. **Add model-specific strategies locally** - Own selectors, topology checks,
communication, and fallback in the distributed autoencoder.
5. **Wire the pipeline** - Replace the pipeline VAE with its distributed version.
6. **Test strategy and lifecycle parity** - Compare one rank with every supported
strategy, including feature-off, fallback, and mixed-request sequences.
+1 -1
View File
@@ -48,7 +48,7 @@ implementation contract; it is not, by itself, a general support claim.
- [Pipeline Parallel](feature/pipeline_parallel.md) - [Pipeline Parallel](feature/pipeline_parallel.md)
- [Sequence Parallel](feature/sequence_parallel.md) - [Sequence Parallel](feature/sequence_parallel.md)
- [Tensor Parallel](feature/tensor_parallel.md) - [Tensor Parallel](feature/tensor_parallel.md)
- [VAE Patch Parallelism](feature/vae_parallel.md) - [VAE Parallel](feature/vae_parallel.md)
#### Attention optimization #### Attention optimization
+1 -3
View File
@@ -42,8 +42,7 @@ th {
| `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | | `Wan22VACEPipeline` | Wan2.1-VACE | `Wan-AI/Wan2.1-VACE-1.3B-diffusers`, `Wan-AI/Wan2.1-VACE-14B-diffusers` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
| `Wan22VACEPipeline` | Wan2.2-VACE | `Pyros13/Wan2.2-VACE-Fun-A14B-Diffusers` | ✅︎ | | | | — | | `Wan22VACEPipeline` | Wan2.2-VACE | `Pyros13/Wan2.2-VACE-Fun-A14B-Diffusers` | ✅︎ | | | | — |
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | — | | `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | | — |
| `LTX2TwoStagePipeline` | LTX-2 / LTX-2.3 ordinary two-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` + matching Lightricks LoRA and upsampler | ✅︎ | ✅︎ | | | — | | `LTX2DistilledPipeline` | LTX-2 distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | | — |
| `LTX2DistilledPipeline` | LTX-2 / LTX-2.3 full-distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled`, `diffusers/LTX-2.3-Distilled-Diffusers` + `Lightricks/LTX-2.3` upsampler | ✅︎ | ✅︎ | | | — |
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — | | `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) | | `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) |
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | | `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
@@ -59,7 +58,6 @@ th {
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — | | `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — |
| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | | — | | `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | | — |
| `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | | — | | `VoxCPM2TalkerForConditionalGeneration` | VoxCPM2 | `openbmb/VoxCPM2` | ✅︎ | | | | — |
| `DotsTTSForConditionalGeneration` | dots.tts | `rednote-hilab/dots.tts-soar` | ✅︎ | | | | — |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | — | | `MammothModa2ForConditionalGeneration` | MammothModa2-Preview | `bytedance-research/MammothModa2-Preview` | ✅︎ | ✅︎ | | | — |
| `MammothModa2ForConditionalGeneration` | MammothModa2-Dev (AR-only image understanding) | `bytedance-research/MammothModa2-Dev` | ✅︎ | | | | — | | `MammothModa2ForConditionalGeneration` | MammothModa2-Dev (AR-only image understanding) | `bytedance-research/MammothModa2-Dev` | ✅︎ | | | | — |
| `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | | `Flux2KleinPipeline` | FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B`, `black-forest-labs/FLUX.2-klein-9B` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — |
@@ -10,7 +10,7 @@ This guide covers the parallelism methods in vLLM-Omni for speeding up diffusion
| **[Sequence Parallelism](sequence_parallel.md)** | Splits sequence dimension across GPUs (Ulysses-SP, Ring-Attention, or hybrid) for high-resolution images and videos | | **[Sequence Parallelism](sequence_parallel.md)** | Splits sequence dimension across GPUs (Ulysses-SP, Ring-Attention, or hybrid) for high-resolution images and videos |
| **[CFG-Parallel](cfg_parallel.md)** | Runs CFG positive/negative branches on separate GPUs for ~1.8x speedup on guided generation | | **[CFG-Parallel](cfg_parallel.md)** | Runs CFG positive/negative branches on separate GPUs for ~1.8x speedup on guided generation |
| **[Pipeline Parallelism](pipeline_parallel.md)** | Splits the denoising transformer block-wise across sequential GPU stages to reduce per-GPU model memory | | **[Pipeline Parallelism](pipeline_parallel.md)** | Splits the denoising transformer block-wise across sequential GPU stages to reduce per-GPU model memory |
| **[VAE Parallelism](vae_parallelism.md)** | Distributes VAE decode spatially across GPUs to reduce peak VAE memory | | **[VAE Parallel](vae_parallel.md)** | Distributes VAE decode spatially across GPUs to reduce peak VAE memory |
| **[HSDP](hsdp.md)** | Shards full model weights via PyTorch FSDP2 to enable large-model inference on memory-constrained GPUs | | **[HSDP](hsdp.md)** | Shards full model weights via PyTorch FSDP2 to enable large-model inference on memory-constrained GPUs |
| **[Expert Parallelism](expert_parallel.md)** | Shards MoE expert blocks across GPUs for MoE models (e.g. HunyuanImage3.0) | | **[Expert Parallelism](expert_parallel.md)** | Shards MoE expert blocks across GPUs for MoE models (e.g. HunyuanImage3.0) |
@@ -1,4 +1,4 @@
# VAE Parallelism Guide # VAE Parallel Guide
## Table of Content ## Table of Content
@@ -113,7 +113,7 @@ In `DiffusionParallelConfig`:
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `vae_patch_parallel_size` | int | 1 | Number of GPUs for VAE patch/tile parallelism. Set to 2 or higher to enable. Should typically match `tensor_parallel_size` as they share the same process group. | | `vae_patch_parallel_size` | int | 1 | Number of GPUs for VAE patch/tile parallelism. Set to 2 or higher to enable. Should typically match `tensor_parallel_size` as they share the same process group. |
| `vae_parallel_mode` | str | `"tile"` | VAE parallel decode strategy: `"tile"` (default tile/patch parallel decode), `"spatial_shard_height"`, or `"spatial_shard_width"` (spatially-sharded decode, Wan only). See [Spatially-Sharded Decode](#spatially-sharded-decode-wan). | | `vae_parallel_mode` | str | `"tile"` | VAE parallel decode strategy: `"tile"`, `"auto"` (Wan request-shape selection), `"spatial_shard_height"`, or `"spatial_shard_width"`. Spatial sharding is currently Wan-only. See [Spatially-Sharded Decode](#spatially-sharded-decode-wan). |
Additional requirements: Additional requirements:
@@ -128,7 +128,7 @@ Additional requirements:
## Spatially-Sharded Decode (Wan) ## Spatially-Sharded Decode (Wan)
The default `vae_parallel_mode="tile"` distributes whole tiles across ranks. For the **Wan** VAE there is an alternative decode strategy, **spatially-sharded decode**, selected via `vae_parallel_mode="spatial_shard_height"` or `vae_parallel_mode="spatial_shard_width"`. The default `vae_parallel_mode="tile"` keeps patch/tile parallel decode unchanged. For Wan, set `vae_parallel_mode="auto"` to select spatial-shard decode only after a request is already large enough to enter Diffusers' tiled-decode path and the VAE uses the full DiT decode group. Auto mode splits the longer eligible spatial axis to reduce halo traffic. Direct-decode requests and partial groups fall back to tile decode. Use `"spatial_shard_height"` or `"spatial_shard_width"` to force an axis.
Instead of assigning independent tiles to ranks, spatial-shard decode shards the decoder feature maps along the height (`spatial_shard_height`) or width (`spatial_shard_width`) dimension and exchanges halo rows/columns between neighboring ranks around the spatial convolutions. This keeps the receptive field correct across shard boundaries, so the result matches the single-GPU decode within numerical tolerance. Instead of assigning independent tiles to ranks, spatial-shard decode shards the decoder feature maps along the height (`spatial_shard_height`) or width (`spatial_shard_width`) dimension and exchanges halo rows/columns between neighboring ranks around the spatial convolutions. This keeps the receptive field correct across shard boundaries, so the result matches the single-GPU decode within numerical tolerance.
@@ -141,7 +141,7 @@ omni = Omni(
parallel_config=DiffusionParallelConfig( parallel_config=DiffusionParallelConfig(
tensor_parallel_size=2, tensor_parallel_size=2,
vae_patch_parallel_size=2, # must match the DiT group size vae_patch_parallel_size=2, # must match the DiT group size
vae_parallel_mode="spatial_shard_width", # or "spatial_shard_height" vae_parallel_mode="auto", # opt in to Wan request-shape selection
), ),
) )
``` ```
@@ -152,14 +152,14 @@ Or from the CLI / serving entrypoint:
vllm serve Wan-AI/Wan2.1-T2V-1.3B-Diffusers --omni \ vllm serve Wan-AI/Wan2.1-T2V-1.3B-Diffusers --omni \
--tensor-parallel-size 2 \ --tensor-parallel-size 2 \
--vae-patch-parallel-size 2 \ --vae-patch-parallel-size 2 \
--vae-parallel-mode spatial_shard_width --vae-parallel-mode auto
``` ```
**Constraints and behavior:** **Constraints and behavior:**
- Spatial-shard decode is **decode-only** and currently implemented for the **Wan** VAE. Other models ignore `spatial_shard_*` modes. - Spatial-shard decode is **decode-only** and currently implemented for the **Wan** VAE. Other VAE families retain their existing mode handling and may reject explicit Wan spatial modes.
- It requires `vae_patch_parallel_size` to **match the DiT process group size**. If it does not, the VAE logs a warning and **falls back to tile-parallel decode** at runtime. - It requires `vae_patch_parallel_size` to **match the DiT process group size**. `auto` quietly falls back to tile decode for a partial group; an explicitly forced spatial mode logs a warning.
- `spatial_shard_height` and `spatial_shard_width` are mutually exclusive for a given VAE instance (the decoder is patched in place for a single split dimension). - The decoder's context-aware wrappers retain the direct path, so one process can alternate small/large and portrait/landscape requests safely.
For end-to-end latency/throughput, launch serving with the desired `vae_parallel_mode` and use the existing diffusion serving benchmark: For end-to-end latency/throughput, launch serving with the desired `vae_parallel_mode` and use the existing diffusion serving benchmark:
+1 -1
View File
@@ -61,7 +61,7 @@ Memory optimization methods help reduce GPU memory usage, enabling inference on
|--------|-------------|----------| |--------|-------------|----------|
| **[CPU Offload](diffusion/cpu_offload.md)** | Offloads model components to CPU memory | Limited VRAM, large models on consumer GPUs | | **[CPU Offload](diffusion/cpu_offload.md)** | Offloads model components to CPU memory | Limited VRAM, large models on consumer GPUs |
| **[Quantization](quantization/overview.md)** | Reduces transformer stages from BF16 to FP8/INT8/etc. | Limited VRAM, minimal accuracy loss | | **[Quantization](quantization/overview.md)** | Reduces transformer stages from BF16 to FP8/INT8/etc. | Limited VRAM, minimal accuracy loss |
| **[VAE Parallelism](diffusion/parallelism/vae_parallelism.md)** | Distributes VAE decode work across GPUs | High-resolution generation with reduced VAE memory peak | | **[VAE Parallel](diffusion/parallelism/vae_parallel.md)** | Distributes VAE decode work across GPUs | High-resolution generation with reduced VAE memory peak |
### Extensions ### Extensions
+1 -3
View File
@@ -80,14 +80,12 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
## Model Type Support ## Model Type Support
### Diffusion Models ### Diffusion Model (Qwen-Image, Wan2.2)
| Model | HF models | Online | Pre-calibrated | Recommendation | `ignored_layers` | Text-Encoder quantization | | Model | HF models | Online | Pre-calibrated | Recommendation | `ignored_layers` | Text-Encoder quantization |
|-------|-----------|:-------:|:------:|----------------|------------------|------------------| |-------|-----------|:-------:|:------:|----------------|------------------|------------------|
| Qwen-Image | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes | Yes | Skip sensitive image-stream MLPs when quality regresses | `img_mlp` | | | Qwen-Image | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes | Yes | Skip sensitive image-stream MLPs when quality regresses | `img_mlp` | |
| Wan2.2 | Wan2.2 diffusion pipelines | Not validated | Not validated | Validate against BF16 before documenting as supported | TBD | | | Wan2.2 | Wan2.2 diffusion pipelines | Not validated | Not validated | Validate against BF16 before documenting as supported | TBD | |
| LTX-2 | `Lightricks/LTX-2`, `rootonchair/LTX-2-19b-distilled` | Yes | Not validated | Transformer only; use dynamic phase LoRA for ordinary two-stage | None | |
| LTX-2.3 | `diffusers/LTX-2.3-Diffusers`, `diffusers/LTX-2.3-Distilled-Diffusers` | Yes | Not validated | Transformer only; use dynamic phase LoRA for ordinary two-stage | None | |
| Z-Image | `Tongyi-MAI/Z-Image-Turbo` | Yes | Yes | All layers | None | ✅︎ | | Z-Image | `Tongyi-MAI/Z-Image-Turbo` | Yes | Yes | All layers | None | ✅︎ |
| FLUX.1 | `black-forest-labs/FLUX.1-dev`, `black-forest-labs/FLUX.1-schnell` | Yes | Yes | All layers | None | | | FLUX.1 | `black-forest-labs/FLUX.1-dev`, `black-forest-labs/FLUX.1-schnell` | Yes | Yes | All layers | None | |
| FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | Yes | Not validated | All layers | None | ✅︎ | | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | Yes | Not validated | All layers | None | ✅︎ |
@@ -23,7 +23,6 @@ list of supported architectures across all modalities, see
| OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz | | OmniVoice | `k2-fsa/OmniVoice` | 2 (gen + dec) | ✓ | — | voice design, language hint | 24 kHz |
| Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants | 24 kHz | | Qwen3-TTS | `Qwen/Qwen3-TTS-12Hz-1.7B-{CustomVoice,VoiceDesign,Base}` | 2 (talker + code2wav) | ✓ (Base) | ✓ | 3 task variants | 24 kHz |
| VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | ✓ (online) | continuation | 48 kHz | | VoxCPM2 | `openbmb/VoxCPM2` | single (native AR) | ✓ | ✓ (online) | continuation | 48 kHz |
| dots.tts | `rednote-hilab/dots.tts-soar` | single (native AR) | — (not wired yet) | — | — | 48 kHz |
| IndexTTS-2 | `IndexTeam/IndexTTS-2` | 2 (AR talker + S2Mel DiT + BigVGAN) | ✓ (required) | — | emotion control (`--emo-audio`, `--emo-text`, `--emo-vector`) | 22.05 kHz | | IndexTTS-2 | `IndexTeam/IndexTTS-2` | 2 (AR talker + S2Mel DiT + BigVGAN) | ✓ (required) | — | emotion control (`--emo-audio`, `--emo-text`, `--emo-vector`) | 22.05 kHz |
| IndexTTS-2.5 | native `checkpoints/` bundle | 2 (AR talker + EnhancedCodec + S2Mel DiT + BigVGAN) | ✓ (required) | — | multilingual (`--lang`) + emotion control | 22.05 kHz | | IndexTTS-2.5 | native `checkpoints/` bundle | 2 (AR talker + EnhancedCodec + S2Mel DiT + BigVGAN) | ✓ (required) | — | multilingual (`--lang`) + emotion control | 22.05 kHz |
| Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets | 24 kHz | | Voxtral TTS | `mistralai/Voxtral-4B-TTS-2603` | varies | ✓ | ✓ | voice presets | 24 kHz |
@@ -431,31 +430,6 @@ Streaming is exposed through the online OpenAI Speech API (`stream=true`). See [
--- ---
## dots.tts
Single-stage native AR TTS at 48 kHz (rednote-hilab). Pipeline: `Qwen2.5-1.5B base LM → DiT (10-step Euler flow matching) → patch_encoder AR loopback → AudioVAE (streaming decode)`. Same "vLLM-native base LM + side-path computation" pattern as VoxCPM2, with a plain Qwen2 backbone instead of MiniCPM4 and no FSQ / residual LM stage.
### Quick start
```bash
python examples/offline_inference/text_to_speech/dots_tts/end2end.py \
--model rednote-hilab/dots.tts-soar \
--text "Hello, this is a test of dots TTS running on vLLM Omni."
```
### Voice cloning
Not wired in this release — generation is zero-shot only. The CAM++ x-vector speaker encoder weights load, but `end2end.py` has no `--ref-audio`/`--ref-text` flags yet.
### Streaming
The AudioVAE decoder has an internal streaming path (`init_stream_state` / `stream_step` / `stream_flush`) used to avoid boundary artifacts between 160 ms patches, but it is not yet exposed through an online serving endpoint or example.
### Notes
- Output: 48 kHz mono WAV.
- Deploy config: `vllm_omni/deploy/dots_tts.yaml` (auto-loaded by HF `model_type`).
- Checkpoints: `rednote-hilab/dots.tts-soar` is the validated default. `dots.tts-base` shares the same architecture but is unvalidated in this repo. `dots.tts-mf` (MeanFlow, 2-4 step) is not supported yet.
- Known limitation: no CUDA graph capture and no batched side-path computation yet, so concurrent requests do not currently scale (each request's DiT Euler steps run serially). See `recipes/rednote-hilab/dots.tts.md` for details and the roadmap.
---
## IndexTTS-2 and IndexTTS-2.5 ## IndexTTS-2 and IndexTTS-2.5
Both versions use a 2-stage TTS pipeline and produce 22.05 kHz mono audio. Stage 0 is a GPT AR talker. IndexTTS-2 Stage 1 uses RepCodec semantic embeddings, GPT latent, S2Mel CFM/DiT, and BigVGAN. IndexTTS-2.5 replaces RepCodec with EnhancedCodec and uses the official code-only path (`use_gpt_latent=false`). Every request requires reference audio for zero-shot voice cloning. Both versions use a 2-stage TTS pipeline and produce 22.05 kHz mono audio. Stage 0 is a GPT AR talker. IndexTTS-2 Stage 1 uses RepCodec semantic embeddings, GPT latent, S2Mel CFM/DiT, and BigVGAN. IndexTTS-2.5 replaces RepCodec with EnhancedCodec and uses the official code-only path (`use_gpt_latent=false`). Every request requires reference audio for zero-shot voice cloning.
@@ -1,125 +0,0 @@
"""Offline dots.tts inference example (single-stage native AR pipeline).
Uses the single-stage native AR config (vllm_omni/deploy/dots_tts.yaml).
Reference checkpoint: rednote-hilab/dots.tts-soar.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
import soundfile as sf
import torch
from vllm_omni import Omni
from vllm_omni.utils.tracking_parser import TrackingArgumentParser
REPO_ROOT = Path(__file__).resolve().parents[4]
SAMPLE_RATE = 48_000
def parse_args():
parser = TrackingArgumentParser(description="Offline dots.tts native AR inference")
parser.add_argument(
"--model",
type=str,
default="rednote-hilab/dots.tts-soar",
help="dots.tts model path or HuggingFace repo ID.",
)
parser.add_argument(
"--text",
type=str,
default="Hello, this is a test of dots TTS running on vLLM Omni.",
help="Text to synthesize.",
)
parser.add_argument(
"--output-dir",
type=str,
default="output_audio",
help="Directory for output WAV files.",
)
parser.add_argument(
"--deploy-config",
type=str,
default=None,
help="Override the deploy config path. If unset, auto-loads "
"vllm_omni/deploy/dots_tts.yaml based on the HF model_type.",
)
return parser.parse_args()
def extract_audio(multimodal_output: dict) -> torch.Tensor:
"""Extract the final complete audio tensor from multimodal output.
The output processor concatenates per-step delta tensors under
``model_outputs``. Falls back to ``audio`` for backwards compat.
"""
audio = multimodal_output.get("model_outputs")
if audio is None:
audio = multimodal_output.get("audio")
if audio is None:
raise ValueError(f"No audio key in multimodal_output: {list(multimodal_output.keys())}")
if isinstance(audio, list):
valid = [torch.as_tensor(a).float().cpu().reshape(-1) for a in audio if a is not None]
if not valid:
raise ValueError("Audio list is empty or all elements are None.")
return torch.cat(valid, dim=0) if len(valid) > 1 else valid[0]
return torch.as_tensor(audio).float().cpu().reshape(-1)
def main():
args = parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
engine = Omni(
model=args.model,
deploy_config=args.deploy_config,
)
from transformers import AutoTokenizer
from vllm_omni.model_executor.models.dots_tts.dots_tts_prompt import (
build_dots_tts_prompt,
)
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
prompt = build_dots_tts_prompt(
tokenizer=tokenizer,
text=args.text,
)
print(f"Model : {args.model}")
print(f"Text : {args.text}")
print(f"Output dir : {output_dir}")
print(f"Prompt len : {len(prompt['prompt_token_ids'])} tokens")
t_start = time.perf_counter()
outputs = engine.generate([prompt])
elapsed = time.perf_counter() - t_start
request_output = outputs[0]
mm = request_output.outputs[0].multimodal_output
audio = extract_audio(mm)
duration = audio.numel() / SAMPLE_RATE
rtf = elapsed / duration if duration > 0 else float("inf")
output_path = output_dir / "output.wav"
sf.write(str(output_path), audio.numpy(), SAMPLE_RATE, format="WAV")
print(f"Saved : {output_path}")
print(f"Duration : {duration:.2f}s")
print(f"Inference : {elapsed:.2f}s")
print(f"RTF : {rtf:.3f}")
if __name__ == "__main__":
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
main()
+46 -72
View File
@@ -4,20 +4,11 @@
## Pipelines ## Pipelines
| `--model-class-name` | Task | Required checkpoint repositories | | Model | Checkpoint | Task | `--model-class-name` | Batching |
|---|---|---| |---|---|---|---|---|
| `LTX2Pipeline` | LTX-2 one-stage T2V/I2V | `Lightricks/LTX-2` | | LTX-2 | `Lightricks/LTX-2` | One-stage T2V/I2V | `LTX2Pipeline` | Yes |
| `LTX2TwoStagePipeline` | LTX-2 ordinary two-stage T2V/I2V | `Lightricks/LTX-2` | | LTX-2 distilled | `rootonchair/LTX-2-19b-distilled` | Two-stage T2V/I2V | `LTX2DistilledPipeline` | No |
| `LTX2DistilledPipeline` | LTX-2 full-distilled two-stage T2V/I2V | `rootonchair/LTX-2-19b-distilled` | | LTX-2.3 | `diffusers/LTX-2.3-Diffusers` | One-stage T2V/I2V | `LTX2Pipeline` | Yes |
| `LTX2Pipeline` | LTX-2.3 one-stage T2V/I2V | `diffusers/LTX-2.3-Diffusers` |
| `LTX2TwoStagePipeline` | LTX-2.3 ordinary two-stage T2V/I2V | `diffusers/LTX-2.3-Diffusers`<br>`Lightricks/LTX-2.3` |
| `LTX2DistilledPipeline` | LTX-2.3 full-distilled two-stage T2V/I2V | `diffusers/LTX-2.3-Distilled-Diffusers`<br>`Lightricks/LTX-2.3` |
Repositories in the table are download units. A full pipeline repository
contains the Transformer, text encoder, connectors, VAEs, vocoder, scheduler,
and tokenizer; an additional repository supplies LoRA or upsampler sidecars.
The runtime first searches the model root for sidecars under their official
filenames, then downloads them from the matching Lightricks Hub repository.
`LTX2Pipeline` is the unified one-stage entry. Checkpoint metadata selects the `LTX2Pipeline` is the unified one-stage entry. Checkpoint metadata selects the
LTX-2 or LTX-2.3 profile; omitting an image selects T2V, while one initial LTX-2 or LTX-2.3 profile; omitting an image selects T2V, while one initial
@@ -25,10 +16,10 @@ image selects I2V. Both one-stage repositories declare this class, so
`--model-class-name` is optional. LTX-2.3 requires the Diffusers checkpoint; `--model-class-name` is optional. LTX-2.3 requires the Diffusers checkpoint;
the raw `Lightricks/LTX-2.3` safetensors repository is not directly loadable. the raw `Lightricks/LTX-2.3` safetensors repository is not directly loadable.
`LTX2TwoStagePipeline` samples the regular model at half resolution, upsamples, `LTX2DistilledPipeline` is the unified distilled entry: omit `image` for T2V
then refines with the distilled LoRA. `LTX2DistilledPipeline` uses a fully or provide one initial image for I2V. As in the official LTX pipelines,
merged distilled Transformer in both stages. Both entries support T2V and I2V; distilled mode is selected explicitly rather than inferred from the checkpoint
select their class explicitly. name or metadata; always pass `--model-class-name LTX2DistilledPipeline`.
## API Migration ## API Migration
@@ -77,28 +68,24 @@ The model recipe also supplies the default negative prompt. Top-level
API defaults `num_frames` to `1`, so set it explicitly; the offline LTX scripts API defaults `num_frames` to `1`, so set it explicitly; the offline LTX scripts
default to `121`. default to `121`.
## Two-Stage Defaults ## Distilled Two-Stage Defaults
| Parameter | Ordinary | Full-distilled | | Parameter | Value |
|---|---:|---:| |---|---:|
| Final width × height | 1536 × 1024 | 1536 × 1024 | | Final width × height | 1536 × 1024 |
| Stage 1 width × height | 768 × 512 | 768 × 512 | | Stage 1 width × height | 768 × 512 |
| Frames / frame rate | 121 / 24 | 121 / 24 | | Frames / frame rate | 121 / 24 |
| Stage 1 / Stage 2 steps | 40 (LTX-2) or 30 (LTX-2.3) / 3 | 8 / 3 | | Stage 1 / Stage 2 denoise steps | 8 / 3 |
| Guidance | Stage 1 guided; Stage 2 positive-only | Fixed positive-only | | Guidance | Fixed positive-only |
API dimensions are final dimensions and must be divisible by 64. All LTX The dimensions passed to the API are the final output dimensions and must be
requests require `num_frames = 8k+1`. Ordinary Stage 1 uses the LTX-2 or divisible by 64. Both one- and two-stage requests require `num_frames = 8k+1`.
LTX-2.3 one-stage defaults shown above. Distilled schedules are fixed, so The distilled recipe fixes both sigma schedules; `num_inference_steps` must be
`num_inference_steps`, when supplied, must be `8`. Both entries reject custom `8` when supplied.
sigmas and input latents.
Ordinary two-stage uses layer-fused LoRA for an unquantized BF16 Transformer
and automatically switches to dynamic LoRA when quantization is enabled.
## Serving ## Serving
Start either one-stage checkpoint: Start either checkpoint:
```bash ```bash
vllm serve Lightricks/LTX-2 --omni --stage-init-timeout 600 vllm serve Lightricks/LTX-2 --omni --stage-init-timeout 600
@@ -108,19 +95,11 @@ vllm serve Lightricks/LTX-2 --omni --stage-init-timeout 600
vllm serve diffusers/LTX-2.3-Diffusers --omni --stage-init-timeout 600 vllm serve diffusers/LTX-2.3-Diffusers --omni --stage-init-timeout 600
``` ```
Start a two-stage checkpoint with its explicit class: Start the distilled checkpoint with its two-stage entry:
```bash ```bash
vllm serve rootonchair/LTX-2-19b-distilled --omni \ vllm serve rootonchair/LTX-2-19b-distilled --omni \
--model-class-name LTX2DistilledPipeline --stage-init-timeout 600 --model-class-name LTX2DistilledPipeline --stage-init-timeout 600
# LTX-2.3 full-distilled; the v1.1 x2 upsampler is resolved when absent
vllm serve diffusers/LTX-2.3-Distilled-Diffusers --omni \
--model-class-name LTX2DistilledPipeline --stage-init-timeout 600
# Ordinary LTX-2.3 two-stage
vllm serve diffusers/LTX-2.3-Diffusers --omni \
--model-class-name LTX2TwoStagePipeline \
--enable-layerwise-offload \
--stage-init-timeout 600
``` ```
The same server handles T2V and I2V. A T2V request using the selected recipe's The same server handles T2V and I2V. A T2V request using the selected recipe's
@@ -156,9 +135,8 @@ together with `input_reference`.
## Guidance ## Guidance
One-stage and ordinary Stage 1 support independent video/audio CFG, LTX supports independent video/audio CFG, spatio-temporal guidance (STG),
spatio-temporal guidance (STG), cross-modality guidance, and rescaling. cross-modality guidance, and rescaling:
Distilled stages and ordinary Stage 2 are fixed positive-only.
| Parameter | Default | Effect | Alias | | Parameter | Default | Effect | Alias |
|---|---:|---|---| |---|---:|---|---|
@@ -194,9 +172,9 @@ complete guidance plan in parallel. Despite the legacy `cfg` name, it covers
text CFG, STG, and cross-modality guidance passes; guidance rescaling is text CFG, STG, and cross-modality guidance passes; guidance rescaling is
applied after all pass predictions are gathered. applied after all pass predictions are gathered.
The default one-stage recipe and ordinary Stage 1 have four Transformer passes The default one-stage recipe has four Transformer passes per denoise step:
per denoise step: `cond`, `uncond`, `ptb` (STG), and `mod` `cond`, `uncond`, `ptb` (STG), and `mod` (cross-modality). The useful balanced
(cross-modality). The useful balanced configurations are therefore: configurations are therefore:
| `--cfg-parallel-size` | Passes per rank | Guidance-slot utilization | Notes | | `--cfg-parallel-size` | Passes per rank | Guidance-slot utilization | Notes |
|---:|---:|---:|---| |---:|---:|---:|---|
@@ -217,8 +195,9 @@ vllm serve diffusers/LTX-2.3-Diffusers --omni \
``` ```
The total device count is the product of `cfg_parallel_size` and the other The total device count is the product of `cfg_parallel_size` and the other
configured parallel dimensions. Positive-only distilled phases do not benefit configured parallel dimensions. Distilled pipelines use positive-only
from `cfg_parallel_size > 1`. guidance, so `cfg_parallel_size > 1` does not provide guidance speedup for
them.
### Python API ### Python API
@@ -263,22 +242,22 @@ noted below.
| `image` | image or batch, `None` | Direct value wins over request images; no image selects T2V. I2V accepts one image per prompt, and a batch cannot mix T2V/I2V. | | `image` | image or batch, `None` | Direct value wins over request images; no image selects T2V. I2V accepts one image per prompt, and a batch cannot mix T2V/I2V. |
| `prompt` | string or list, `None` | Positive-text fallback; request prompts win. Mutually exclusive with `prompt_embeds`. | | `prompt` | string or list, `None` | Positive-text fallback; request prompts win. Mutually exclusive with `prompt_embeds`. |
| `negative_prompt` | string or list, `None` | Fallback after request values and before the recipe default; mutually exclusive with negative embeddings. | | `negative_prompt` | string or list, `None` | Fallback after request values and before the recipe default; mutually exclusive with negative embeddings. |
| `height` | `int`, `None` | Request → direct value → recipe default; divisible by 32 one-stage or 64 two-stage. | | `height` | `int`, `None` | Request → direct value → recipe default; divisible by 32 one-stage or 64 distilled. |
| `width` | `int`, `None` | Same precedence and alignment as `height`. | | `width` | `int`, `None` | Same precedence and alignment as `height`. |
| `num_frames` | `int`, `None` | Request → direct value → recipe default; must be `8k+1` and also determines audio duration with `frame_rate`. | | `num_frames` | `int`, `None` | Request → direct value → recipe default; must be `8k+1` and also determines audio duration with `frame_rate`. |
| `frame_rate` | `float`, `None` | Request `frame_rate` → request `fps` → direct value → recipe default. | | `frame_rate` | `float`, `None` | Request `frame_rate` → request `fps` → direct value → recipe default. |
| `num_inference_steps` | `int`, `None` | Request → direct value → recipe default; minimum 2 one-stage and ordinary Stage 1, fixed at 8 for distilled Stage 1. Custom one-stage `sigmas` determine actual steps. | | `num_inference_steps` | `int`, `None` | Request → direct value → recipe default; minimum 2 one-stage, fixed at 8 for distilled Stage 1. Custom one-stage `sigmas` determine actual steps. |
| `sigmas` | list of float, `None` | One-stage only. Request values win; every request in a fused batch must use the same schedule. | | `sigmas` | list of float, `None` | One-stage only. Request values win; every request in a fused batch must use the same schedule. |
| `timesteps` | list of int, `None` | Compatibility slot; LTX accepts only `None`. Use `sigmas`. | | `timesteps` | list of int, `None` | Compatibility slot; LTX accepts only `None`. Use `sigmas`. |
| `guidance_scale` | `float`, `None` | One-stage and ordinary Stage 1 common video/audio CFG fallback; an explicit request value wins. Distilled phases use fixed positive-only guidance. | | `guidance_scale` | `float`, `None` | One-stage common video/audio CFG fallback; an explicit request value wins. Distilled two-stage uses fixed positive-only guidance. |
| `guidance_rescale` | `float`, `None` | Accepts only `None` or `0.0`; use the modality rescale fields. | | `guidance_rescale` | `float`, `None` | Accepts only `None` or `0.0`; use the modality rescale fields. |
| `noise_scale` | `float`, `0.0` | Compatibility slot; LTX accepts only `0.0`. | | `noise_scale` | `float`, `0.0` | Compatibility slot; LTX accepts only `0.0`. |
| `num_videos_per_prompt` | `int`, `1` | Output-count fallback; positive request `num_outputs_per_prompt` wins. | | `num_videos_per_prompt` | `int`, `1` | Output-count fallback; positive request `num_outputs_per_prompt` wins. |
| `generator` | generator or list, `None` | Explicit RNG; otherwise request generators/seeds are collated. Lists must match the effective output batch. | | `generator` | generator or list, `None` | Explicit RNG; otherwise request generators/seeds are collated. Lists must match the effective output batch. |
| `latents` | tensor, `None` | One-stage only. Request tensors win; packed `[B, S, C]` and validated unpacked video layouts are accepted. | | `latents` | tensor, `None` | Request tensors win. One-stage accepts packed `[B, S, C]` and validated unpacked video layouts. |
| `audio_latents` | tensor, `None` | One-stage only. Request tensors win and are collated. | | `audio_latents` | tensor, `None` | One-stage initial audio latents; request tensors win and are collated. |
| `prompt_embeds` | tensor, `None` | Precomputed positive conditioning; requires `prompt_attention_mask` and cannot accompany `prompt`. | | `prompt_embeds` | tensor, `None` | Precomputed positive conditioning; requires `prompt_attention_mask` and cannot accompany `prompt`. |
| `negative_prompt_embeds` | tensor, `None` | One-stage and ordinary Stage 1 conditioning; requires its mask and cannot accompany `negative_prompt`. | | `negative_prompt_embeds` | tensor, `None` | One-stage precomputed negative conditioning; requires its mask and cannot accompany `negative_prompt`. |
| `prompt_attention_mask` | tensor, `None` | Mask for positive embeddings; request `prompt_attention_mask` or `attention_mask` wins. | | `prompt_attention_mask` | tensor, `None` | Mask for positive embeddings; request `prompt_attention_mask` or `attention_mask` wins. |
| `negative_prompt_attention_mask` | tensor, `None` | Mask for negative embeddings; request negative mask fields win. | | `negative_prompt_attention_mask` | tensor, `None` | Mask for negative embeddings; request negative mask fields win. |
| `decode_timestep` | float or list, `0.0` | Video-VAE decode timestep; request value wins. Lists may match 1, prompt batch, or output batch. | | `decode_timestep` | float or list, `0.0` | Video-VAE decode timestep; request value wins. Lists may match 1, prompt batch, or output batch. |
@@ -294,13 +273,13 @@ request prompt payload; LTX guidance fields live in sampling `extra_args`.
### Recipe-Specific Request Capabilities ### Recipe-Specific Request Capabilities
| Override | One-stage | Ordinary two-stage | Distilled two-stage | | Override | One-stage | Distilled two-stage |
|---|---|---|---| |---|---|---|
| Guidance | Supported | Stage 1 only; Stage 2 is positive-only | Fixed positive-only | | Guidance | Supported | Fixed positive-only |
| Negative prompt/embeddings | Supported | Supported by Stage 1 | Rejected | | Negative prompt/embeddings | Supported | Rejected |
| `num_inference_steps` | Supported | Controls Stage 1; Stage 2 uses 3 | Fixed at 8 for Stage 1; Stage 2 uses 3 | | `num_inference_steps` | Supported | Fixed at 8 for Stage 1; Stage 2 uses 3 |
| Custom `sigmas` | Supported | Rejected | Rejected; both phases use fixed schedules | | Custom `sigmas` | Supported | Rejected; both phases use fixed schedules |
| Video/audio latents | Supported | Rejected | Rejected | | Video/audio latents | Supported | Rejected |
These capability checks apply equally to direct `forward` keywords and These capability checks apply equally to direct `forward` keywords and
values in `OmniDiffusionSamplingParams`; unsupported values fail instead of values in `OmniDiffusionSamplingParams`; unsupported values fail instead of
@@ -330,10 +309,6 @@ bundled offline CLI do not currently expose `sigmas`.
`--max-num-seqs 1`. `--max-num-seqs 1`.
- `--cfg-parallel-size` shards the complete LTX guidance plan, including STG, - `--cfg-parallel-size` shards the complete LTX guidance plan, including STG,
modality guidance, and rescale-compatible prediction gathering. modality guidance, and rescale-compatible prediction gathering.
- Sequence parallelism may pad audio latents. Pure Ulysses masks the padding;
Ring cannot, so audio length must be SP-divisible. Use `ring_degree=1` or a
divisible request shape.
- Cache-DiT is one-stage only; multi-stage configurations are rejected.
## Operational Notes ## Operational Notes
@@ -348,15 +323,14 @@ bundled offline CLI do not currently expose `sigmas`.
request parameter. request parameter.
- For benchmarks, use `tests/dfx/perf/tests/test_ltx2_vllm_omni.json` with - For benchmarks, use `tests/dfx/perf/tests/test_ltx2_vllm_omni.json` with
`tests/dfx/perf/scripts/run_diffusion_benchmark.py`. `tests/dfx/perf/scripts/run_diffusion_benchmark.py`.
- Ordinary and full-distilled two-stage T2V/I2V are supported; HQ execution - Current two-stage support is limited to the distilled checkpoint; official
remains out of scope. LTX two-stage/HQ execution remains out of scope.
## References ## References
- <https://huggingface.co/Lightricks/LTX-2> - <https://huggingface.co/Lightricks/LTX-2>
- <https://huggingface.co/Lightricks/LTX-2.3> - <https://huggingface.co/Lightricks/LTX-2.3>
- <https://huggingface.co/diffusers/LTX-2.3-Diffusers> - <https://huggingface.co/diffusers/LTX-2.3-Diffusers>
- <https://huggingface.co/diffusers/LTX-2.3-Distilled-Diffusers>
- [Online video generation](../../docs/user_guide/examples/online_serving/text_to_video.md) - [Online video generation](../../docs/user_guide/examples/online_serving/text_to_video.md)
- [Diffusion execution modes](../../docs/user_guide/diffusion/execution_modes.md) - [Diffusion execution modes](../../docs/user_guide/diffusion/execution_modes.md)
- [T2V offline example](../../examples/offline_inference/text_to_video/text_to_video.md) - [T2V offline example](../../examples/offline_inference/text_to_video/text_to_video.md)
-152
View File
@@ -1,152 +0,0 @@
# dots.tts
> Offline continuous-AR TTS at 48 kHz (rednote-hilab)
## Summary
- Vendor: rednote-hilab
- Model: `rednote-hilab/dots.tts-soar`
- Task: Text-to-speech, zero-shot synthesis only
- Mode: Offline end-to-end example only (not yet wired for online serving —
see [Known limitations](#known-limitations))
- Maintainer: Community
## When to use this recipe
Use this recipe as a known-good starting point for running
`rednote-hilab/dots.tts-soar` offline on vLLM-Omni on consumer-class GPUs.
dots.tts is a ~1.7B-parameter continuous-AR TTS model (Qwen2.5-1.5B base LM
+ 344M DiT flow-matching head + 180M AudioVAE) that emits 48 kHz mono audio.
It follows the same "vLLM-native base LM + side-path computation" pattern as
VoxCPM2 — single-stage pipeline
`Qwen2.5-1.5B base LM → DiT (10-step Euler flow matching) → patch_encoder AR
loopback → AudioVAE (streaming decode)` — with a plain Qwen2 backbone
instead of MiniCPM4, and no FSQ / residual-LM stage.
This is an early integration (see [Known limitations](#known-limitations)
below before depending on it for anything beyond offline experimentation).
## References
- Offline end-to-end script:
[`examples/offline_inference/text_to_speech/dots_tts/end2end.py`](../../examples/offline_inference/text_to_speech/dots_tts/end2end.py)
- Example guide:
[`examples/offline_inference/text_to_speech/README.md`](../../examples/offline_inference/text_to_speech/README.md#dotstts)
- Default deploy config:
[`vllm_omni/deploy/dots_tts.yaml`](../../vllm_omni/deploy/dots_tts.yaml)
- Talker / pipeline source:
[`vllm_omni/model_executor/models/dots_tts/`](../../vllm_omni/model_executor/models/dots_tts/)
- Upstream: [rednote-hilab/dots.tts](https://github.com/rednote-hilab/dots.tts)
## Hardware Support
This recipe documents one tested 16 GB consumer-GPU configuration. Other
vendor sections (ROCm, NPU) and larger-VRAM configurations are welcome as
community validation lands.
## GPU
### 1 x RTX 5080 16GB (Single GPU, Minimum Recommended)
dots.tts (~1.7B params across the base LM + DiT + AudioVAE + CAM++
speaker encoder, bfloat16) fits comfortably on a single 16 GB GPU. The
bundled default config at
[`vllm_omni/deploy/dots_tts.yaml`](../../vllm_omni/deploy/dots_tts.yaml)
(`gpu_memory_utilization: 0.8`, `max_num_seqs: 4`, `enforce_eager: true`,
`enable_prefix_caching: false`) loads cleanly with ~5.1 GiB for model
weights and ~0.3 GiB peak activation; the remainder of the configured
budget is available for KV cache. Total resident footprint at idle is
roughly **7-8 GiB / 16 GB** — the only tight spot in the full CUDA-Graph
roadmap would be step 8's graph capture (not implemented yet; this
release runs `enforce_eager: true`, so it doesn't apply today).
#### Environment
- OS: Linux (WSL2)
- Python: 3.12
- Driver / runtime: NVIDIA driver 595.95
- torch: 2.11.0+cu130
- vLLM: 0.26.0
- vLLM-Omni: 0.22.1.dev (current `main`)
#### Command
```bash
python examples/offline_inference/text_to_speech/dots_tts/end2end.py \
--model rednote-hilab/dots.tts-soar \
--text "Hello, this is a test of dots TTS running on vLLM Omni."
```
The deploy config at
[`vllm_omni/deploy/dots_tts.yaml`](../../vllm_omni/deploy/dots_tts.yaml)
is loaded automatically by the model registry (HF `model_type=dots_tts`).
Pass `--deploy-config <path>` to override.
#### Verification
**T1 — offline zero-shot synthesis**:
```bash
python examples/offline_inference/text_to_speech/dots_tts/end2end.py \
--model rednote-hilab/dots.tts-soar \
--text "Hello, this is a test of dots TTS running on vLLM Omni."
```
Observed: `output_audio/output.wav`, 3.52 s @ 48 kHz mono. Single
one-shot process (init → one `generate()` → exit), so the reported
numbers include engine init and first-request warmup, not just steady-state
per-step throughput — same caveat as VoxCPM2's recipe. `Inference: 5.52s`,
`RTF: 1.569`.
Weight-loading breakdown from the same run (all tensors matched, no
missing/extra keys): 951/951 AudioVAE, 244/244 DiT, 270/270
patch_encoder, 198 Qwen2, 938/938 CAM++ speaker encoder.
Whisper transcription of the output matched the input text with no
dropped leading word (confirms the streaming-vocoder patch-boundary fix
described in [Known limitations](#known-limitations)).
#### Notes
- Output: 48 kHz mono WAV.
- Checkpoints: `rednote-hilab/dots.tts-soar` is the validated default
used throughout this recipe. `rednote-hilab/dots.tts-base` shares the
same architecture but is unvalidated in this repo. `rednote-hilab/dots.tts-mf`
(MeanFlow, 2-4 step) is not supported — see below.
- `enforce_eager: true` and `enable_prefix_caching: false` in the deploy
config are load-bearing, not just conservative defaults: with prefix
caching enabled, vLLM-Omni's prefix-cache multimodal-output merge path
does not preserve the `sparse_audio` marker this model relies on to
route audio output correctly, and generation silently truncates to a
single ~160 ms patch. Do not override `enable_prefix_caching` for this
model until that framework-level gap is fixed.
## Known limitations
- **No online serving yet.** dots.tts is not registered in
`vllm_omni/entrypoints/openai/tts_adapters/` (the framework other TTS
models in this repo use for `/v1/audio/speech`), so `vllm serve
rednote-hilab/dots.tts-soar --omni` will start but the OpenAI-compatible
speech endpoint does not know how to build request params for it yet.
Offline `Omni()` (as used in `end2end.py`) is the only supported path
today.
- **Voice cloning is not wired.** The CAM++ x-vector speaker encoder
weights load and are exercised by `load_weights()`, but nothing in the
prompt builder consumes a reference audio yet — generation is zero-shot
only. `end2end.py` has no `--ref-audio`/`--ref-text` flags.
- **`dots.tts-mf` (MeanFlow, 2-4 step) checkpoint is not supported.** Only
the fixed 10-step Euler DiT sampler used by `dots.tts-soar` /
`dots.tts-base` is implemented.
- **No CUDA graph capture.** The talker runs fully eager. voxcpm2's three
captured graphs (base LM decode, CFM solver, VAE decode) have no
dots.tts equivalent yet.
- **Concurrent requests do not scale.** Each request's 10-step DiT Euler
integration runs serially in the side path (no cross-request batching,
unlike voxcpm2's `enable_batched_cfm`). A community review of this
integration measured no throughput gain at `c=4` concurrent requests
versus `c=1`.
- **`SamplingParams.seed` does not control audio-generation randomness.**
The DiT's flow-matching noise is deterministically derived per-request
from a fixed internal seed (reproducible run-to-run, matching
voxcpm2's `deterministic_cfm_seed` convention) rather than from the
caller-supplied `seed` field — the same limitation voxcpm2 has today.
+4 -1
View File
@@ -471,7 +471,6 @@ def test_sub_config_fields_match_structured_scopes():
"trust_remote_code", "trust_remote_code",
"dtype", "dtype",
"attention_backend", "attention_backend",
"attention_config",
"moe_backend", "moe_backend",
"hf_overrides", "hf_overrides",
"limit_mm_per_prompt", "limit_mm_per_prompt",
@@ -583,8 +582,12 @@ def test_diffusion_parallel_config_keeps_current_diffusion_parallel_surface():
assert cfg.data_parallel_size == 3 assert cfg.data_parallel_size == 3
assert cfg.cfg_parallel_size == 3 assert cfg.cfg_parallel_size == 3
assert cfg.mask_sp_padding is True assert cfg.mask_sp_padding is True
assert cfg.vae_parallel_mode == "tile"
assert cfg.world_size == 72 assert cfg.world_size == 72
auto_cfg = OmniStageDiffusionParallelConfig(vae_parallel_mode="auto")
assert auto_cfg.vae_parallel_mode == "auto"
def test_parallel_config_derived_fields_are_not_init_inputs(): def test_parallel_config_derived_fields_are_not_init_inputs():
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
@@ -1,4 +1,5 @@
import os import os
from contextlib import contextmanager
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -10,6 +11,7 @@ from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import ( from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
DistributedAutoencoderKLWan, DistributedAutoencoderKLWan,
select_auto_spatial_shard_split_dim,
) )
from vllm_omni.platforms import current_omni_platform from vllm_omni.platforms import current_omni_platform
@@ -17,6 +19,23 @@ from vllm_omni.platforms import current_omni_platform
# the end uses hardware_test (H100 x2) plus full_model / diffusion / parallel. # the end uses hardware_test (H100 x2) plus full_model / diffusion / parallel.
@contextmanager
def _active_spatial_shard(split_dim: str):
token = wan_spatial_shard._SPATIAL_SHARD_CONTEXT.set(
wan_spatial_shard.SpatialShardContext(
input_extent=2,
local_input_extent=2,
split_dim=split_dim,
rank=0,
world_size=3,
)
)
try:
yield
finally:
wan_spatial_shard._SPATIAL_SHARD_CONTEXT.reset(token)
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.cpu @pytest.mark.cpu
def test_split_for_parallel_decode_pads_uneven_height(): def test_split_for_parallel_decode_pads_uneven_height():
@@ -332,7 +351,7 @@ def test_attention_wrapper_rejects_spatial_extent_change(monkeypatch: pytest.Mon
lambda x, *, group, dim, dst=None: torch.cat([x, x], dim=dim), lambda x, *, group, dim, dst=None: torch.cat([x, x], dim=dim),
) )
module = ShrinkingWanAttentionBlock() module = ShrinkingWanAttentionBlock()
wan_spatial_shard._patch_attention_block(module, group=object(), split_dim="width") wan_spatial_shard._patch_attention_block(module, group=object())
x = torch.zeros((1, 1, 1, 1, 2), dtype=torch.float32) x = torch.zeros((1, 1, 1, 1, 2), dtype=torch.float32)
token = wan_spatial_shard._SPATIAL_SHARD_CONTEXT.set( token = wan_spatial_shard._SPATIAL_SHARD_CONTEXT.set(
wan_spatial_shard.SpatialShardContext( wan_spatial_shard.SpatialShardContext(
@@ -377,12 +396,14 @@ def test_dist_zero_pad_only_applies_global_height_edges(monkeypatch: pytest.Monk
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 3)) monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 3))
mid_rank_pad = wan_spatial_shard.WanDistZeroPad2d((0, 1, 1, 1), group=object()) mid_rank_pad = wan_spatial_shard.WanDistZeroPad2d((0, 1, 1, 1), group=object())
mid = mid_rank_pad(x) with _active_spatial_shard("height"):
mid = mid_rank_pad(x)
assert mid.shape == (1, 1, 2, 3) assert mid.shape == (1, 1, 2, 3)
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3)) monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3))
last_rank_pad = wan_spatial_shard.WanDistZeroPad2d((0, 1, 1, 1), group=object()) last_rank_pad = wan_spatial_shard.WanDistZeroPad2d((0, 1, 1, 1), group=object())
last = last_rank_pad(x) with _active_spatial_shard("height"):
last = last_rank_pad(x)
assert last.shape == (1, 1, 3, 3) assert last.shape == (1, 1, 3, 3)
@@ -392,13 +413,15 @@ def test_dist_zero_pad_only_applies_global_width_edges(monkeypatch: pytest.Monke
x = torch.ones((1, 1, 2, 2)) x = torch.ones((1, 1, 2, 2))
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 3)) monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (1, 3))
mid_rank_pad = wan_spatial_shard.WanDistZeroPad2d((1, 1, 0, 0), group=object(), split_dim="width") mid_rank_pad = wan_spatial_shard.WanDistZeroPad2d((1, 1, 0, 0), group=object())
mid = mid_rank_pad(x) with _active_spatial_shard("width"):
mid = mid_rank_pad(x)
assert mid.shape == (1, 1, 2, 2) assert mid.shape == (1, 1, 2, 2)
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3)) monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (2, 3))
last_rank_pad = wan_spatial_shard.WanDistZeroPad2d((1, 1, 0, 0), group=object(), split_dim="width") last_rank_pad = wan_spatial_shard.WanDistZeroPad2d((1, 1, 0, 0), group=object())
last = last_rank_pad(x) with _active_spatial_shard("width"):
last = last_rank_pad(x)
assert last.shape == (1, 1, 2, 3) assert last.shape == (1, 1, 2, 3)
@@ -421,6 +444,111 @@ def test_spatial_shard_height_gate_falls_back_for_partial_group(monkeypatch: pyt
assert vae._spatial_shard_decode_enabled(z) is False assert vae._spatial_shard_decode_enabled(z) is False
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize(
"z_shape,world_size,expected",
[
((1, 16, 1, 60, 104), 4, "width"),
((1, 16, 5, 60, 104), 4, "width"),
((1, 16, 5, 104, 60), 4, "height"),
((1, 16, 5, 2, 2), 4, None),
((1, 16, 5, 64, 64), 4, "width"),
((1, 16, 64, 64), 4, None),
((1, 16, 5, 64, 64), 1, None),
],
)
def test_auto_spatial_shard_selector_is_shape_aware(z_shape, world_size, expected):
assert select_auto_spatial_shard_split_dim(z_shape, world_size) == expected
@pytest.mark.core_model
@pytest.mark.cpu
def test_auto_spatial_shard_gate_requires_full_group(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan.dist.get_world_size",
lambda group=None: 4,
)
vae = DistributedAutoencoderKLWan.__new__(DistributedAutoencoderKLWan)
vae.use_tiling = True
vae.distributed_executor = SimpleNamespace(group=object(), parallel_size=4, parallel_mode="auto")
vae.is_distributed_enabled = lambda: True
large = torch.zeros((1, 16, 5, 60, 104))
unshardable = torch.zeros((1, 16, 1, 2, 2))
unsupported = torch.zeros((1, 16, 60, 104))
assert vae._spatial_shard_decode_split_dim(large, 4) == "width"
assert vae._spatial_shard_decode_enabled(large) is True
assert vae._spatial_shard_decode_enabled(unshardable) is False
assert vae._spatial_shard_decode_enabled(unsupported) is False
vae.distributed_executor.parallel_size = 2
assert vae._spatial_shard_decode_enabled(large) is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_set_parallel_size_prepares_spatial_wrappers_only_for_a_full_group(monkeypatch: pytest.MonkeyPatch):
vae = DistributedAutoencoderKLWan.__new__(DistributedAutoencoderKLWan)
executor = SimpleNamespace(group=object(), parallel_size=1, parallel_mode="tile")
def set_parallel_size(parallel_size: int, mode: str = "tile") -> None:
executor.parallel_size = parallel_size
executor.parallel_mode = mode
executor.set_parallel_size = set_parallel_size
object.__setattr__(vae, "distributed_executor", executor)
installed = []
monkeypatch.setattr(
"vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan.dist.get_world_size",
lambda group=None: 4,
)
monkeypatch.setattr(
wan_spatial_shard,
"install_wan_spatial_shard_decode",
lambda candidate, group, split_dim: installed.append((candidate, group, split_dim)),
)
vae.set_parallel_size(2, mode="auto")
assert installed == []
vae.set_parallel_size(4, mode="auto")
assert installed == [(vae, vae.distributed_executor.group, "height")]
@pytest.mark.core_model
@pytest.mark.cpu
@torch.no_grad()
def test_installed_spatial_decoder_keeps_direct_fallback_and_can_change_axis():
vae = DistributedAutoencoderKLWan(
base_dim=8,
decoder_base_dim=8,
z_dim=4,
dim_mult=[1, 2],
num_res_blocks=1,
attn_scales=[],
temperal_downsample=[False],
latents_mean=[0.0] * 4,
latents_std=[1.0] * 4,
scale_factor_temporal=1,
scale_factor_spatial=2,
).eval()
latents = torch.randn(1, 4, 1, 4, 5)
parameter_names = {name for name, _ in vae.named_parameters()}
state_dict = {name: value.clone() for name, value in vae.state_dict().items()}
reference = vae.decode(latents, return_dict=False)[0]
wan_spatial_shard.install_wan_spatial_shard_decode(vae, group=object(), split_dim="height")
assert any(getattr(module, "_vllm_omni_dynamic_spatial_shard_conv", False) for module in vae.decoder.modules())
wan_spatial_shard.install_wan_spatial_shard_decode(vae, group=object(), split_dim="width")
assert {name for name, _ in vae.named_parameters()} == parameter_names
vae.load_state_dict(state_dict, strict=True)
assert torch.equal(vae.decode(latents, return_dict=False)[0], reference)
@pytest.mark.core_model @pytest.mark.core_model
@pytest.mark.cpu @pytest.mark.cpu
def test_spatial_shard_width_gate_selects_width(monkeypatch: pytest.MonkeyPatch): def test_spatial_shard_width_gate_selects_width(monkeypatch: pytest.MonkeyPatch):
@@ -566,3 +694,99 @@ def test_spatial_shard_decode_matches_reference(split_dim: str):
assert mean_abs_diff <= _SPATIAL_SHARD_TOLERANCE, ( assert mean_abs_diff <= _SPATIAL_SHARD_TOLERANCE, (
f"spatial_shard_{split_dim} mean_abs_diff {mean_abs_diff} exceeds {_SPATIAL_SHARD_TOLERANCE}" f"spatial_shard_{split_dim} mean_abs_diff {mean_abs_diff} exceeds {_SPATIAL_SHARD_TOLERANCE}"
) )
def _auto_spatial_shard_decode_worker(rank: int, return_dict, master_port: str) -> None:
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_model_parallel,
init_distributed_environment,
initialize_model_parallel,
)
os.environ["MASTER_ADDR"] = "127.0.0.1"
os.environ["MASTER_PORT"] = master_port
device = current_omni_platform.get_torch_device(rank)
current_omni_platform.set_device(device)
backend = current_omni_platform.dist_backend
init_distributed_environment(world_size=2, rank=rank, local_rank=rank, backend=backend)
initialize_model_parallel(sequence_parallel_size=2, ulysses_degree=2, backend=backend)
try:
vae = DistributedAutoencoderKLWan.from_pretrained(
_SPATIAL_SHARD_MODEL,
subfolder=_SPATIAL_SHARD_SUBFOLDER,
torch_dtype=torch.float32,
).to(device=device, dtype=torch.float32)
vae.eval()
generator = torch.Generator(device=device).manual_seed(1)
small = torch.randn((1, vae.config.z_dim, 1, 60, 104), generator=generator, device=device)
landscape = torch.randn((1, vae.config.z_dim, 2, 60, 104), generator=generator, device=device)
portrait = torch.randn((1, vae.config.z_dim, 2, 104, 60), generator=generator, device=device)
with torch.inference_mode():
vae.use_tiling = False
vae.set_parallel_size(1, mode="tile")
small_reference = vae.decode(small, return_dict=False)[0]
vae.use_tiling = True
vae.set_parallel_size(2, mode="tile")
tile_before_install = vae.decode(small, return_dict=False)[0]
vae.set_parallel_size(2, mode="auto")
assert vae._spatial_shard_decode_split_dim(small, 2) == "width"
small_sharded = vae.decode(small, return_dict=False)[0]
assert vae._spatial_shard_decode_split_dim(landscape, 2) == "width"
vae.decode(landscape, return_dict=False)
vae.set_parallel_size(2, mode="tile")
tile_after_install = vae.decode(small, return_dict=False)[0]
vae.use_tiling = False
vae.set_parallel_size(1, mode="tile")
portrait_reference = vae.decode(portrait, return_dict=False)[0]
vae.use_tiling = True
vae.set_parallel_size(2, mode="auto")
assert vae._spatial_shard_decode_split_dim(portrait, 2) == "height"
portrait_sharded = vae.decode(portrait, return_dict=False)[0]
assert all(
buffer is None
for module in vae.decoder.modules()
for name, buffer in module._buffers.items()
if name in {"_halo_recv_top_buf", "_halo_recv_bottom_buf"}
)
if rank == 0:
small_diff = (small_sharded.float() - small_reference.float()).abs()
portrait_diff = (portrait_sharded.float() - portrait_reference.float()).abs()
return_dict["tile_fallback_exact"] = torch.equal(tile_after_install, tile_before_install)
return_dict["small_max_abs_diff"] = small_diff.max().item()
return_dict["small_mean_abs_diff"] = small_diff.mean().item()
return_dict["portrait_max_abs_diff"] = portrait_diff.max().item()
return_dict["portrait_mean_abs_diff"] = portrait_diff.mean().item()
finally:
destroy_model_parallel()
if dist.is_initialized():
dist.destroy_process_group()
@pytest.mark.full_model
@pytest.mark.diffusion
@pytest.mark.parallel
@hardware_test(res={"cuda": "H100"}, num_cards=2)
def test_auto_spatial_shard_switches_shape_paths_safely():
manager = mp.get_context("spawn").Manager()
return_dict = manager.dict()
mp.spawn(
_auto_spatial_shard_decode_worker,
args=(return_dict, "29502"),
nprocs=2,
join=True,
)
assert return_dict.get("tile_fallback_exact") is True
assert return_dict["small_max_abs_diff"] <= _SPATIAL_SHARD_TOLERANCE
assert return_dict["small_mean_abs_diff"] <= _SPATIAL_SHARD_TOLERANCE
assert return_dict["portrait_max_abs_diff"] <= _SPATIAL_SHARD_TOLERANCE
assert return_dict["portrait_mean_abs_diff"] <= _SPATIAL_SHARD_TOLERANCE
@@ -1,82 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
import torch.nn.functional as F
from vllm.triton_utils import HAS_TRITON
pytestmark = [pytest.mark.core_model, pytest.mark.cuda, pytest.mark.diffusion]
_HEAD_DIM = 128
_ROTARY_DIM = 96
_EPS = 1e-5
def _reference(q, k, q_weight, k_weight, rope_table):
q = F.rms_norm(q, (_HEAD_DIM,), q_weight, _EPS)
k = F.rms_norm(k, (_HEAD_DIM,), k_weight, _EPS)
half = _ROTARY_DIM // 2
cos = rope_table[..., :half].unsqueeze(1)
sin = rope_table[..., half:].unsqueeze(1)
def apply(x):
first = x[..., :half]
second = x[..., half:_ROTARY_DIM]
return torch.cat(
(
first * cos - second * sin,
second * cos + first * sin,
x[..., _ROTARY_DIM:],
),
dim=-1,
)
return apply(q), apply(k)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
@pytest.mark.parametrize("seq_len", [1, 257, 1024])
def test_fused_qk_norm_rope_matches_bf16_reference(seq_len):
from vllm_omni.diffusion.layers.fused_qk_norm_rope import (
fused_qk_norm_rope,
)
torch.manual_seed(17)
heads = 14
qkv = torch.randn(
seq_len,
heads * _HEAD_DIM * 3,
device="cuda",
dtype=torch.bfloat16,
)
q = qkv[:, : heads * _HEAD_DIM].view(seq_len, heads, _HEAD_DIM)
k = qkv[:, heads * _HEAD_DIM : 2 * heads * _HEAD_DIM].view(
seq_len,
heads,
_HEAD_DIM,
)
q_weight = torch.randn(_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
k_weight = torch.randn(_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
freqs = torch.randn(seq_len, _ROTARY_DIM // 2, device="cuda")
rope_table = torch.cat((torch.cos(freqs), torch.sin(freqs)), dim=-1).to(torch.bfloat16)
expected_q, expected_k = _reference(
q,
k,
q_weight,
k_weight,
rope_table,
)
actual_q, actual_k = fused_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
_EPS,
)
torch.testing.assert_close(actual_q, expected_q, atol=0.0625, rtol=0.02)
torch.testing.assert_close(actual_k, expected_k, atol=0.0625, rtol=0.02)
@@ -23,7 +23,7 @@ def _make_pipeline(pipeline_cls, sequence_parallel_size: int = 1):
return pipeline return pipeline
def test_prepare_video_latents_matches_official_values_and_token_major_layout(): def test_prepare_video_latents_samples_directly_in_packed_token_space():
pipeline = _make_pipeline(LTX2Pipeline) pipeline = _make_pipeline(LTX2Pipeline)
pipeline.vae_spatial_compression_ratio = 8 pipeline.vae_spatial_compression_ratio = 8
pipeline.vae_temporal_compression_ratio = 8 pipeline.vae_temporal_compression_ratio = 8
@@ -44,7 +44,6 @@ def test_prepare_video_latents_matches_official_values_and_token_major_layout():
) )
torch.testing.assert_close(actual, expected, rtol=0, atol=0) torch.testing.assert_close(actual, expected, rtol=0, atol=0)
assert actual.stride()[1:] == (1, actual.shape[1])
def test_prepare_audio_latents_samples_directly_in_packed_token_space(): def test_prepare_audio_latents_samples_directly_in_packed_token_space():
@@ -82,42 +81,6 @@ def test_prepare_audio_latents_pads_generated_dummy_length_for_sp():
assert original_num_frames == 1 assert original_num_frames == 1
assert padded_num_frames == 2 assert padded_num_frames == 2
assert latents.shape == (1, 2, 128) assert latents.shape == (1, 2, 128)
torch.testing.assert_close(latents[:, 1:], torch.zeros_like(latents[:, 1:]))
def test_prepare_audio_latents_request_rng_is_invariant_to_sp_padding():
pipeline_sp1 = _make_pipeline(LTX2Pipeline, sequence_parallel_size=1)
pipeline_sp4 = _make_pipeline(LTX2Pipeline, sequence_parallel_size=4)
generator_sp1 = torch.Generator().manual_seed(42)
generator_sp4 = torch.Generator().manual_seed(42)
latents_sp1, _, _ = pipeline_sp1.prepare_audio_latents(
batch_size=1,
num_channels_latents=2,
num_mel_bins=8,
audio_latent_length=3,
dtype=torch.float32,
device=torch.device("cpu"),
generator=generator_sp1,
)
latents_sp4, _, _ = pipeline_sp4.prepare_audio_latents(
batch_size=1,
num_channels_latents=2,
num_mel_bins=8,
audio_latent_length=3,
dtype=torch.float32,
device=torch.device("cpu"),
generator=generator_sp4,
)
torch.testing.assert_close(latents_sp4[:, :3], latents_sp1, rtol=0, atol=0)
torch.testing.assert_close(latents_sp4[:, 3:], torch.zeros_like(latents_sp4[:, 3:]))
torch.testing.assert_close(
torch.randn(8, generator=generator_sp4),
torch.randn(8, generator=generator_sp1),
rtol=0,
atol=0,
)
def test_prepare_audio_latents_pads_packed_sequence_dim_for_provided_latents(): def test_prepare_audio_latents_pads_packed_sequence_dim_for_provided_latents():
@@ -174,9 +137,7 @@ def test_prepare_audio_latents_accepts_already_padded_4d_latents_for_sp():
assert original_num_frames == 10 assert original_num_frames == 10
assert padded_num_frames == 12 assert padded_num_frames == 12
assert padded.shape == (1, 12, 8) assert padded.shape == (1, 12, 8)
packed = latent_ops.pack_audio_latents(latents) torch.testing.assert_close(padded, latent_ops.pack_audio_latents(latents))
torch.testing.assert_close(padded[:, :10], packed[:, :10])
torch.testing.assert_close(padded[:, 10:], torch.zeros_like(padded[:, 10:]))
def test_resolve_audio_latent_length_preserves_legacy_4d_shape_inference(): def test_resolve_audio_latent_length_preserves_legacy_4d_shape_inference():
@@ -12,19 +12,6 @@ import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_velocity_from_x0_uses_official_host_scalar_sigma(mocker):
from vllm_omni.diffusion.models.ltx2.ltx2_guidance import velocity_from_x0
sigma = mocker.MagicMock()
sigma.to.return_value.item.return_value = 0.5
actual = velocity_from_x0(torch.tensor([1.0]), torch.tensor([0.0]), sigma)
sigma.to.assert_called_once_with(torch.float32)
sigma.to.return_value.item.assert_called_once_with()
torch.testing.assert_close(actual, torch.tensor([2.0]))
class TestCFGParallelHelpers: class TestCFGParallelHelpers:
"""Test LTX-2.3 CFG helper math without loading model weights.""" """Test LTX-2.3 CFG helper math without loading model weights."""
@@ -212,7 +199,6 @@ class TestCFGParallelHelpers:
), ),
attention_kwargs=None, attention_kwargs=None,
audio_scheduler=SimpleNamespace(sigmas=torch.stack([audio_sigma])), audio_scheduler=SimpleNamespace(sigmas=torch.stack([audio_sigma])),
original_audio_num_frames=state.audio.shape[1],
) )
actual_video, actual_audio = executor.predict_parallel_guidance( actual_video, actual_audio = executor.predict_parallel_guidance(
@@ -1,483 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for the fixed LTX refinement-phase adapter."""
from types import SimpleNamespace
import pytest
import torch
from safetensors.torch import save_file
from vllm_omni.diffusion.models.ltx2 import ltx2_phase_adapter
from vllm_omni.diffusion.models.ltx2.ltx2_adapter_parser import (
AdapterTarget,
parse_ltx_adapter,
)
from vllm_omni.diffusion.models.ltx2.ltx2_components import LTX23_TWO_STAGE_COMPONENT_PROFILE
from vllm_omni.diffusion.models.ltx2.ltx2_phase_adapter import LTXPhaseAdapterRuntime
from vllm_omni.diffusion.models.ltx2.ltx2_recipes import (
LTX2_TWO_STAGE_RECIPE,
LTX_DISTILLED_ADAPTER_SLOT,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _target(
lora_a,
lora_b,
*,
source_module="proj",
module="proj",
target_slice=None,
):
return AdapterTarget(
source_module=source_module,
module=module,
slice=target_slice,
a_key="a",
b_key="b",
a_shape=tuple(lora_a.shape),
b_shape=tuple(lora_b.shape),
)
def _wrap(
layer,
lora_a,
lora_b,
*,
source_module="proj",
module="proj",
target_slice=None,
layer_fused=False,
):
target = _target(
lora_a,
lora_b,
source_module=source_module,
module=module,
target_slice=target_slice,
)
wrapper = ltx2_phase_adapter._PhaseAdapterLinear(
layer,
[target],
adapter_dtype=lora_a.dtype,
layer_fused=layer_fused,
)
wrapper.load_target(target, lora_a, lora_b)
wrapper.set_enabled(True)
return wrapper
class _LoRAAttention(torch.nn.Module):
def __init__(self) -> None:
super().__init__()
self.to_qkv = torch.nn.Identity()
class _LoRATransformer(torch.nn.Module):
stacked_params_mapping = ((".attn1.to_qkv", ".attn1.to_q", "q"),)
def __init__(self) -> None:
super().__init__()
self.proj_in = torch.nn.Linear(4, 6, bias=False)
block = torch.nn.Module()
block.attn1 = _LoRAAttention()
self.transformer_blocks = torch.nn.ModuleList([block])
def test_ltx_adapter_parser_maps_official_and_packed_module_names(tmp_path):
adapter_path = tmp_path / "distilled.safetensors"
save_file(
{
"diffusion_model.text_embedding_projection.aggregate_embed.lora_A.weight": torch.randn(2, 4),
"diffusion_model.patchify_proj.lora_A.weight": torch.randn(2, 4),
"diffusion_model.patchify_proj.lora_B.weight": torch.randn(6, 2),
"diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight": torch.randn(2, 4),
"diffusion_model.transformer_blocks.0.attn1.to_q.lora_B.weight": torch.randn(4, 2),
},
adapter_path,
)
manifest = parse_ltx_adapter(_LoRATransformer(), str(adapter_path))
assert {(target.source_module, target.module, target.slice) for target in manifest.targets} == {
("proj_in", "proj_in", None),
("transformer_blocks.0.attn1.to_q", "transformer_blocks.0.attn1.to_qkv", "q"),
}
def test_ltx_adapter_parser_rejects_unpaired_b_tensor(tmp_path):
adapter_path = tmp_path / "distilled.safetensors"
save_file(
{"diffusion_model.patchify_proj.lora_B.weight": torch.randn(6, 2)},
adapter_path,
)
with pytest.raises(ValueError, match="Missing paired LoRA A tensors"):
parse_ltx_adapter(_LoRATransformer(), str(adapter_path))
def _pipeline(*, dtype, quantization_config):
return SimpleNamespace(
pipeline_recipe=LTX2_TWO_STAGE_RECIPE,
component_profile=LTX23_TWO_STAGE_COMPONENT_PROFILE,
transformer=object(),
od_config=SimpleNamespace(
model="unused",
lora_path=None,
dtype=dtype,
quantization_config=quantization_config,
),
)
@pytest.mark.parametrize(
("dtype", "quantization_config", "layer_fused"),
[
(torch.float32, object(), False),
(torch.bfloat16, None, True),
],
)
def test_ltx_phase_adapter_selects_execution_from_quantization(
monkeypatch,
dtype,
quantization_config,
layer_fused,
):
pipeline = _pipeline(dtype=dtype, quantization_config=quantization_config)
manifest = object()
captured = {}
def resolve_artifact(*_args, **kwargs):
assert not kwargs
return "adapter"
def parse(transformer, path):
assert transformer is pipeline.transformer
assert path == "adapter"
return manifest
class Runtime:
def __init__(self, transformer, parsed_manifest, **kwargs):
captured.update(transformer=transformer, manifest=parsed_manifest, **kwargs)
monkeypatch.setattr(ltx2_phase_adapter, "resolve_ltx_artifact", resolve_artifact)
monkeypatch.setattr(ltx2_phase_adapter, "parse_ltx_adapter", parse)
monkeypatch.setattr(ltx2_phase_adapter, "LTXPhaseAdapterRuntime", Runtime)
runtime = ltx2_phase_adapter.build_ltx_phase_adapter(pipeline)
assert isinstance(runtime, Runtime)
assert captured == {
"transformer": pipeline.transformer,
"manifest": manifest,
"dtype": dtype,
"layer_fused": layer_fused,
}
def test_ltx_phase_adapter_rejects_non_bfloat16_layer_fused(monkeypatch):
pipeline = _pipeline(dtype=torch.float16, quantization_config=None)
monkeypatch.setattr(ltx2_phase_adapter, "resolve_ltx_artifact", lambda *_args, **_kwargs: "adapter")
with pytest.raises(ValueError, match="bfloat16"):
ltx2_phase_adapter.build_ltx_phase_adapter(pipeline)
def test_ltx_phase_adapter_keeps_one_transformer_and_switches_a_fixed_slot(tmp_path):
class TinyTransformer(torch.nn.Module):
stacked_params_mapping = ()
def __init__(self) -> None:
super().__init__()
self.proj = torch.nn.Linear(2, 2, bias=False)
adapter_path = tmp_path / "distilled.safetensors"
save_file(
{
"diffusion_model.proj.lora_A.weight": torch.tensor([[1.0, 2.0]]),
"diffusion_model.proj.lora_B.weight": torch.tensor([[3.0], [4.0]]),
},
adapter_path,
)
transformer = TinyTransformer()
runtime = LTXPhaseAdapterRuntime(
transformer,
parse_ltx_adapter(transformer, str(adapter_path)),
dtype=torch.float32,
)
assert runtime.base_parameter_name("proj.weight") == "proj.base_layer.weight"
assert {name for name, _ in transformer.named_parameters()} == {"proj.base_layer.weight"}
with torch.no_grad():
transformer.proj.base_layer.weight.copy_(torch.eye(2))
runtime.finalize()
x = torch.tensor([[1.0, 1.0]])
runtime.activate(None)
torch.testing.assert_close(transformer.proj(x), torch.tensor([[1.0, 1.0]]))
runtime.activate(LTX_DISTILLED_ADAPTER_SLOT)
torch.testing.assert_close(transformer.proj(x), torch.tensor([[10.0, 13.0]]))
def test_ltx_layer_fused_adapter_matches_official_bf16_weight_fusion():
layer = torch.nn.Linear(4, 3, bias=True, dtype=torch.bfloat16)
base_weight = torch.tensor(
[
[0.5, -0.25, 0.125, 0.75],
[-0.375, 0.625, 0.25, -0.5],
[0.875, -0.125, -0.75, 0.375],
],
dtype=torch.bfloat16,
)
lora_a = torch.tensor(
[
[0.1015625, -0.203125, 0.3046875, -0.40625],
[0.5078125, -0.609375, 0.7109375, -0.8125],
],
dtype=torch.bfloat16,
)
lora_b = torch.tensor(
[[0.9140625, -0.1171875], [-0.21875, 0.3203125], [0.421875, -0.5234375]],
dtype=torch.bfloat16,
)
with torch.no_grad():
layer.weight.copy_(base_weight)
layer.bias.copy_(torch.tensor([0.125, -0.25, 0.5], dtype=torch.bfloat16))
wrapper = _wrap(layer, lora_a, lora_b, layer_fused=True)
expected_weight = torch.matmul(lora_b, lora_a)
expected_weight.add_(base_weight)
x = torch.tensor([[0.6015625, -0.703125, 0.8046875, -0.90625]], dtype=torch.bfloat16)
expected = torch.nn.functional.linear(x, expected_weight, layer.bias)
assert torch.equal(wrapper(x), expected)
assert torch.equal(layer.weight, base_weight)
def test_ltx_layer_fused_row_parallel_uses_one_collective(monkeypatch):
from vllm.model_executor.layers.linear import UnquantizedLinearMethod
class FakeRow(torch.nn.Module):
input_size = 4
output_size = 2
input_size_per_partition = 2
input_is_parallel = True
tp_rank = 0
tp_size = 2
reduce_results = True
skip_bias_add = False
return_bias = False
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(torch.eye(2, dtype=torch.bfloat16))
self.bias = torch.nn.Parameter(torch.tensor([0.25, -0.5], dtype=torch.bfloat16))
self.quant_method = UnquantizedLinearMethod()
monkeypatch.setattr(ltx2_phase_adapter, "RowParallelLinear", FakeRow)
reductions = []
def all_reduce(value):
reductions.append(value.clone())
return value + 1
monkeypatch.setattr(ltx2_phase_adapter, "tensor_model_parallel_all_reduce", all_reduce)
layer = FakeRow()
lora_a = torch.tensor([[0.5, -0.25, 0.75, -0.125]], dtype=torch.bfloat16)
lora_b = torch.tensor([[0.25], [-0.5]], dtype=torch.bfloat16)
wrapper = _wrap(layer, lora_a, lora_b, layer_fused=True)
expected_weight = torch.matmul(lora_b, lora_a[:, :2])
expected_weight.add_(layer.weight)
x = torch.tensor([[1.0, 2.0]], dtype=torch.bfloat16)
expected_local = torch.nn.functional.linear(x, expected_weight, layer.bias)
actual = wrapper(x)
assert len(reductions) == 1
assert torch.equal(reductions[0], expected_local)
assert torch.equal(actual, expected_local + 1)
def test_ltx_layer_fused_column_parallel_uses_local_weight_shard(monkeypatch):
from vllm.model_executor.layers.linear import UnquantizedLinearMethod
class FakeColumn(torch.nn.Module):
input_size = 3
output_size = 4
output_size_per_partition = 2
tp_rank = 1
tp_size = 2
gather_output = False
skip_bias_add = False
return_bias = False
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(
torch.tensor([[0.5, -0.25, 0.125], [-0.375, 0.625, 0.25]], dtype=torch.bfloat16)
)
self.bias = torch.nn.Parameter(torch.tensor([0.25, -0.5], dtype=torch.bfloat16))
self.quant_method = UnquantizedLinearMethod()
monkeypatch.setattr(ltx2_phase_adapter, "ColumnParallelLinear", FakeColumn)
layer = FakeColumn()
base_weight = layer.weight.detach().clone()
lora_a = torch.tensor([[0.5, -0.25, 0.75]], dtype=torch.bfloat16)
lora_b = torch.tensor([[0.25], [-0.5], [0.75], [-1.0]], dtype=torch.bfloat16)
wrapper = _wrap(layer, lora_a, lora_b, layer_fused=True)
expected_weight = torch.matmul(lora_b[2:], lora_a)
expected_weight.add_(base_weight)
x = torch.tensor([[1.0, -0.5, 0.25]], dtype=torch.bfloat16)
expected = torch.nn.functional.linear(x, expected_weight, layer.bias)
assert torch.equal(wrapper(x), expected)
assert torch.equal(layer.weight, base_weight)
def test_ltx_layer_fused_packed_qkv_updates_only_the_target_slice(monkeypatch):
from vllm.model_executor.layers.linear import UnquantizedLinearMethod
class FakeColumn(torch.nn.Module):
pass
class FakeQKV(FakeColumn):
input_size = 4
head_size = 2
v_head_size = 2
total_num_heads = 2
total_num_kv_heads = 2
num_heads = 1
num_kv_heads = 1
num_kv_head_replicas = 1
tp_rank = 1
tp_size = 2
gather_output = False
skip_bias_add = True
return_bias = True
def __init__(self):
super().__init__()
self.weight = torch.nn.Parameter(
torch.arange(24, dtype=torch.float32).reshape(6, 4).to(torch.bfloat16) / 16
)
self.bias = torch.nn.Parameter(torch.arange(6, dtype=torch.float32).to(torch.bfloat16) / 8)
self.quant_method = UnquantizedLinearMethod()
monkeypatch.setattr(ltx2_phase_adapter, "ColumnParallelLinear", FakeColumn)
monkeypatch.setattr(ltx2_phase_adapter, "QKVParallelLinear", FakeQKV)
layer = FakeQKV()
base_weight = layer.weight.detach().clone()
lora_a = torch.tensor([[0.5, -0.25, 0.75, -0.125]], dtype=torch.bfloat16)
lora_b = torch.tensor([[0.25], [-0.5], [0.75], [-1.0]], dtype=torch.bfloat16)
wrapper = _wrap(
layer,
lora_a,
lora_b,
source_module="to_k",
module="to_qkv",
target_slice="k",
layer_fused=True,
)
expected_weight = base_weight.clone()
expected_k = torch.matmul(lora_b[2:], lora_a)
expected_k.add_(base_weight[2:4])
expected_weight[2:4].copy_(expected_k)
x = torch.tensor([[1.0, -0.5, 0.25, -0.125]], dtype=torch.bfloat16)
expected_output = torch.nn.functional.linear(x, expected_weight)
actual_output, returned_bias = wrapper(x)
assert torch.equal(actual_output, expected_output)
assert returned_bias is layer.bias
assert torch.equal(layer.weight, base_weight)
@pytest.mark.parametrize(
("layer_factory", "target_slice", "expected_a", "expected_b", "expected_output"),
[
(
lambda: type(
"FakeColumn",
(torch.nn.Module,),
{
"input_size": 4,
"output_size": 4,
"output_size_per_partition": 2,
"tp_rank": 1,
"gather_output": False,
"forward": lambda self, x: x.new_zeros((*x.shape[:-1], 2)),
},
)(),
None,
torch.tensor([[1.0, 10.0, 100.0, 1000.0]]),
torch.tensor([[1.0], [2.0], [3.0], [4.0]]),
torch.tensor([[12963.0, 17284.0]]),
),
(
lambda: type(
"FakeRow",
(torch.nn.Module,),
{
"input_size": 4,
"output_size": 2,
"input_size_per_partition": 2,
"tp_rank": 1,
"reduce_results": False,
"forward": lambda self, x: x.new_zeros((*x.shape[:-1], 2)),
},
)(),
None,
torch.tensor([[1.0, 10.0, 100.0, 1000.0]]),
torch.tensor([[2.0], [3.0]]),
torch.tensor([[8600.0, 12900.0]]),
),
(
lambda: type(
"FakeQKV",
(torch.nn.Module,),
{
"input_size": 4,
"head_size": 2,
"v_head_size": 2,
"total_num_heads": 2,
"total_num_kv_heads": 2,
"num_heads": 1,
"num_kv_heads": 1,
"num_kv_head_replicas": 1,
"tp_rank": 1,
"forward": lambda self, x: x.new_zeros((*x.shape[:-1], 6)),
},
)(),
"k",
torch.tensor([[1.0, 10.0, 100.0, 1000.0]]),
torch.tensor([[1.0], [2.0], [3.0], [4.0]]),
torch.tensor([[0.0, 0.0, 12963.0, 17284.0, 0.0, 0.0]]),
),
],
)
def test_ltx_phase_adapter_uses_local_tp_lora_slices(
monkeypatch,
layer_factory,
target_slice,
expected_a,
expected_b,
expected_output,
):
layer = layer_factory()
layer.register_buffer("device_anchor", torch.empty(0))
layer_type = type(layer)
if target_slice == "k":
monkeypatch.setattr(ltx2_phase_adapter, "QKVParallelLinear", layer_type)
elif "Column" in layer_type.__name__:
monkeypatch.setattr(ltx2_phase_adapter, "ColumnParallelLinear", layer_type)
else:
monkeypatch.setattr(ltx2_phase_adapter, "RowParallelLinear", layer_type)
wrapper = _wrap(layer, expected_a, expected_b, target_slice=target_slice)
torch.testing.assert_close(wrapper(torch.tensor([[1.0, 2.0, 3.0, 4.0]])), expected_output)
expected_local_a_width = getattr(layer, "input_size_per_partition", layer.input_size)
assert wrapper.pieces[0].lora_a.shape[-1] == expected_local_a_width
+10 -362
View File
@@ -13,21 +13,13 @@ import pytest
import torch import torch
from vllm_omni.diffusion.data import DiffusionOutput from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.models.ltx2 import ( from vllm_omni.diffusion.models.ltx2 import ltx2_components
ltx2_components,
ltx2_transformer,
)
from vllm_omni.diffusion.models.ltx2.ltx2_components import ( from vllm_omni.diffusion.models.ltx2.ltx2_components import (
LTX2_COMPONENT_PROFILE, LTX2_COMPONENT_PROFILE,
LTX2_DISTILLED_COMPONENT_PROFILE, LTX2_DISTILLED_COMPONENT_PROFILE,
LTX2_TWO_STAGE_COMPONENT_PROFILE,
LTX23_COMPONENT_PROFILE, LTX23_COMPONENT_PROFILE,
LTX23_DISTILLED_COMPONENT_PROFILE,
LTX23_TWO_STAGE_COMPONENT_PROFILE,
_install_connector_attention, _install_connector_attention,
detect_ltx_model_version, detect_ltx_model_version,
resolve_ltx_artifact,
resolve_ltx_component_profile,
) )
from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import LTXI2VConditioningMixin from vllm_omni.diffusion.models.ltx2.ltx2_conditioning import LTXI2VConditioningMixin
from vllm_omni.diffusion.models.ltx2.ltx2_denoise import ( from vllm_omni.diffusion.models.ltx2.ltx2_denoise import (
@@ -41,7 +33,6 @@ from vllm_omni.diffusion.models.ltx2.ltx2_guidance import (
LTXGuidancePlan, LTXGuidancePlan,
LTXGuidanceSpec, LTXGuidanceSpec,
LTXModalityGuidance, LTXModalityGuidance,
_repeat_batch,
build_perturbation_kwargs, build_perturbation_kwargs,
combine_guided_x0, combine_guided_x0,
) )
@@ -49,29 +40,18 @@ from vllm_omni.diffusion.models.ltx2.ltx2_latents import LTXAVState
from vllm_omni.diffusion.models.ltx2.ltx2_recipes import ( from vllm_omni.diffusion.models.ltx2.ltx2_recipes import (
LTX2_DISTILLED_TWO_STAGE_RECIPE, LTX2_DISTILLED_TWO_STAGE_RECIPE,
LTX2_ONE_STAGE_RECIPE, LTX2_ONE_STAGE_RECIPE,
LTX2_TWO_STAGE_RECIPE,
LTX23_DISTILLED_TWO_STAGE_RECIPE,
LTX23_ONE_STAGE_RECIPE, LTX23_ONE_STAGE_RECIPE,
LTX23_TWO_STAGE_RECIPE,
LTX_DEFAULT_NEGATIVE_PROMPT, LTX_DEFAULT_NEGATIVE_PROMPT,
LTX_POSITIVE_ONLY_RECIPE, LTX_POSITIVE_ONLY_RECIPE,
resolve_ltx_pipeline_recipe,
)
from vllm_omni.diffusion.models.ltx2.ltx2_request import (
LTXRequestInputs,
validate_ltx_checkpoint,
validate_pipeline_request,
) )
from vllm_omni.diffusion.models.ltx2.ltx2_request import LTXRequestInputs, validate_pipeline_request
from vllm_omni.diffusion.models.ltx2.ltx2_runtime import LTXRuntime from vllm_omni.diffusion.models.ltx2.ltx2_runtime import LTXRuntime
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import ( from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
LTX2I2VDMD2Pipeline, LTX2I2VDMD2Pipeline,
LTX2Pipeline, LTX2Pipeline,
LTX2T2VDMD2Pipeline, LTX2T2VDMD2Pipeline,
) )
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import ( from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import LTX2DistilledPipeline
LTX2DistilledPipeline,
LTX2TwoStagePipeline,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -124,22 +104,10 @@ def test_ltx_public_entries_share_runtime_and_keep_recipe_boundaries():
) )
assert issubclass(LTX2Pipeline, LTXRuntime) assert issubclass(LTX2Pipeline, LTXRuntime)
assert issubclass(LTX2TwoStagePipeline, LTXRuntime)
assert issubclass(LTX2DistilledPipeline, LTXRuntime) assert issubclass(LTX2DistilledPipeline, LTXRuntime)
assert LTX2Pipeline.component_profile is LTX2_COMPONENT_PROFILE assert LTX2Pipeline.component_profile is LTX2_COMPONENT_PROFILE
assert LTX2Pipeline.pipeline_recipe is LTX2_ONE_STAGE_RECIPE assert LTX2Pipeline.pipeline_recipe is LTX2_ONE_STAGE_RECIPE
assert LTX2TwoStagePipeline.component_profile is LTX2_TWO_STAGE_COMPONENT_PROFILE
assert LTX2TwoStagePipeline.pipeline_recipe is LTX2_TWO_STAGE_RECIPE
assert LTX2DistilledPipeline.component_profile is LTX2_DISTILLED_COMPONENT_PROFILE assert LTX2DistilledPipeline.component_profile is LTX2_DISTILLED_COMPONENT_PROFILE
for pipeline_cls, profile in (
(LTX2Pipeline, LTX2_COMPONENT_PROFILE),
(LTX2TwoStagePipeline, LTX2_TWO_STAGE_COMPONENT_PROFILE),
(LTX2DistilledPipeline, LTX2_DISTILLED_COMPONENT_PROFILE),
):
assert pipeline_cls._dit_modules == list(profile.dit_modules)
assert pipeline_cls._encoder_modules == list(profile.encoder_modules)
assert pipeline_cls._vae_modules == list(profile.vae_modules)
assert pipeline_cls._resident_modules == list(profile.resident_modules)
assert LTX2_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video assert LTX2_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video
assert LTX23_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video assert LTX23_COMPONENT_PROFILE.video_vae_cls is DistributedAutoencoderKLLTX2Video
@@ -155,72 +123,6 @@ def test_ltx_checkpoint_version_detection_uses_metadata(tmp_path):
assert detect_ltx_model_version(str(tmp_path)) == "2" assert detect_ltx_model_version(str(tmp_path)) == "2"
def test_ltx_artifact_falls_back_to_unpinned_hub_download(tmp_path, monkeypatch):
filename = "ltx-sidecar.safetensors"
calls = []
def fake_download(**kwargs):
calls.append(kwargs)
return "/cache/ltx-sidecar.safetensors"
monkeypatch.setattr(ltx2_components, "hf_hub_download", fake_download)
assert (
resolve_ltx_artifact(str(tmp_path / "model"), "Lightricks/test", filename) == "/cache/ltx-sidecar.safetensors"
)
assert calls == [{"repo_id": "Lightricks/test", "filename": filename}]
def test_ltx_artifact_prefers_model_root(tmp_path, monkeypatch):
filename = "ltx-sidecar.safetensors"
expected = tmp_path / filename
expected.write_bytes(b"sidecar")
monkeypatch.setattr(ltx2_components, "hf_hub_download", lambda **_kwargs: pytest.fail("unexpected Hub lookup"))
assert resolve_ltx_artifact(str(tmp_path), "Lightricks/test", filename) == str(expected)
@pytest.mark.parametrize(
("expected_kind", "scheduler_config"),
[
("regular", {"use_dynamic_shifting": True, "shift_terminal": 0.1}),
("distilled", {"use_dynamic_shifting": False, "shift_terminal": None}),
],
)
def test_ltx_checkpoint_validation_accepts_matching_scheduler_metadata(expected_kind, scheduler_config):
validate_ltx_checkpoint(
scheduler_config,
expected_kind=expected_kind,
pipeline_name="TestLTXPipeline",
)
@pytest.mark.parametrize(
("expected_kind", "scheduler_config", "error"),
[
("regular", {"use_dynamic_shifting": False, "shift_terminal": None}, "regular non-distilled"),
("distilled", {"use_dynamic_shifting": True, "shift_terminal": 0.1}, "merged distilled"),
],
)
def test_ltx_checkpoint_validation_rejects_mismatched_scheduler_metadata(expected_kind, scheduler_config, error):
with pytest.raises(ValueError, match=error):
validate_ltx_checkpoint(
scheduler_config,
expected_kind=expected_kind,
pipeline_name="TestLTXPipeline",
)
def test_ltx_rejects_advanced_uaa_before_component_initialization():
od_config = SimpleNamespace(
model="unused",
parallel_config=SimpleNamespace(ulysses_mode="advanced_uaa"),
)
with pytest.raises(ValueError, match="does not support ulysses_mode='advanced_uaa'"):
LTX2Pipeline(od_config=od_config)
def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, monkeypatch): def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, monkeypatch):
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
@@ -242,104 +144,16 @@ def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, m
assert pipe.reports_stage_durations assert pipe.reports_stage_durations
def test_ltx_multistage_rejects_cache_dit_before_component_initialization(tmp_path, monkeypatch): def test_ltx_one_stage_entry_exposes_batch_image_and_distributed_decode_contracts():
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
(tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2Vocoder"]}))
initialized = False
def stub_components(*_args, **_kwargs):
nonlocal initialized
initialized = True
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
with pytest.raises(ValueError, match="only one-stage LTX recipes"):
LTX2TwoStagePipeline(
od_config=SimpleNamespace(
model=str(tmp_path),
cache_backend="cache_dit",
)
)
assert not initialized
def test_ltx23_checkpoint_selects_version_specific_two_stage_profiles(tmp_path, monkeypatch):
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
(tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2VocoderWithBWE"]}))
def stub_components(pipe, od_config):
pipe.od_config = od_config
pipe.vae_spatial_compression_ratio = 32
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", lambda *_args, **_kwargs: None)
monkeypatch.setattr(ltx2_runtime, "build_ltx_phase_adapter", lambda *_args: SimpleNamespace())
pipe = LTX2TwoStagePipeline(
od_config=SimpleNamespace(
model=str(tmp_path),
enable_diffusion_pipeline_profiler=False,
lora_path=None,
model_config={},
)
)
assert pipe.model_version == "2.3"
assert pipe.component_profile is LTX23_TWO_STAGE_COMPONENT_PROFILE
assert pipe.pipeline_recipe is LTX23_TWO_STAGE_RECIPE
def test_ltx23_checkpoint_selects_full_distilled_profile_and_recipe(tmp_path, monkeypatch):
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
(tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2VocoderWithBWE"]}))
def stub_components(pipe, od_config):
pipe.od_config = od_config
pipe.vae_spatial_compression_ratio = 32
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", lambda *_args, **_kwargs: None)
pipe = LTX2DistilledPipeline(
od_config=SimpleNamespace(
model=str(tmp_path),
enable_diffusion_pipeline_profiler=False,
)
)
assert pipe.model_version == "2.3"
assert pipe.component_profile is LTX23_DISTILLED_COMPONENT_PROFILE
assert pipe.pipeline_recipe is LTX23_DISTILLED_TWO_STAGE_RECIPE
assert pipe._phase_adapter is None
assert pipe.preserve_sp_padded_audio_duration
assert pipe.reports_stage_durations
def test_ltx_entries_expose_batch_image_and_distributed_decode_contracts():
assert LTX2Pipeline.supports_request_batch assert LTX2Pipeline.supports_request_batch
assert LTX2Pipeline.support_image_input assert LTX2Pipeline.support_image_input
assert LTX2Pipeline.unified_text_image_entry assert LTX2Pipeline.unified_text_image_entry
assert LTX2Pipeline.distributed_video_decode assert LTX2Pipeline.distributed_video_decode
assert not LTX2TwoStagePipeline.supports_request_batch
assert LTX2TwoStagePipeline.support_image_input
assert LTX2TwoStagePipeline.unified_text_image_entry
assert not LTX2DistilledPipeline.supports_request_batch assert not LTX2DistilledPipeline.supports_request_batch
assert LTX2DistilledPipeline.support_image_input assert LTX2DistilledPipeline.support_image_input
assert LTX2DistilledPipeline.unified_text_image_entry assert LTX2DistilledPipeline.unified_text_image_entry
def test_ltx_two_stage_resolves_request_embedded_images():
pipe = object.__new__(LTX2TwoStagePipeline)
request_inputs = SimpleNamespace(latents=None)
image = torch.zeros(3, 8, 8)
req = SimpleNamespace(prompts=[{"prompt": "image conditioned", "multi_modal_data": {"image": image}}])
assert pipe._resolve_request_image(req, None, request_inputs) is image
def test_ltx_dmd2_entries_retain_their_task_boundaries(): def test_ltx_dmd2_entries_retain_their_task_boundaries():
assert not LTX2T2VDMD2Pipeline.support_image_input assert not LTX2T2VDMD2Pipeline.support_image_input
assert LTX2I2VDMD2Pipeline.support_image_input assert LTX2I2VDMD2Pipeline.support_image_input
@@ -516,15 +330,6 @@ def test_ltx_guidance_uses_official_close_to_disabled_semantics():
assert LTXGuidancePlan.build(LTXGuidanceSpec(video=guidance)).names == ("cond",) assert LTXGuidancePlan.build(LTXGuidanceSpec(video=guidance)).names == ("cond",)
def test_ltx_positive_only_guidance_preserves_token_major_layout():
tensor = torch.arange(24).view(1, 3, 8).transpose(1, 2).contiguous().transpose(1, 2)
repeated = _repeat_batch(tensor, 1)
assert repeated is tensor
assert repeated.stride() == tensor.stride()
def test_ltx_guidance_rescale_is_invariant_to_request_batching(): def test_ltx_guidance_rescale_is_invariant_to_request_batching():
cond = torch.tensor( cond = torch.tensor(
[ [
@@ -575,32 +380,6 @@ def test_ltx_guidance_rescale_handles_zero_variance_prediction():
torch.testing.assert_close(actual, torch.zeros_like(cond)) torch.testing.assert_close(actual, torch.zeros_like(cond))
def test_ltx_audio_guidance_rescale_ignores_sp_padding_tokens():
logical_cond = torch.tensor([[[1.0, 2.0], [3.0, 5.0]]])
logical_uncond = torch.tensor([[[0.5, -1.0], [2.0, 1.0]]])
guidance = LTXModalityGuidance(cfg_scale=3.0, rescale_scale=1.0)
expected = combine_guided_x0(
cond=logical_cond,
uncond_text=logical_uncond,
uncond_perturbed=0.0,
uncond_modality=0.0,
guidance=guidance,
)
padded_cond = torch.cat([logical_cond, torch.tensor([[[1000.0, -1000.0]]])], dim=1)
padded_uncond = torch.cat([logical_uncond, torch.tensor([[[-500.0, 500.0]]])], dim=1)
actual = combine_guided_x0(
cond=padded_cond,
uncond_text=padded_uncond,
uncond_perturbed=0.0,
uncond_modality=0.0,
guidance=guidance,
rescale_token_count=2,
)
torch.testing.assert_close(actual[:, :2], expected)
def test_ltx_guidance_builds_per_sample_stg_and_modality_masks(): def test_ltx_guidance_builds_per_sample_stg_and_modality_masks():
plan = LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance) plan = LTXGuidancePlan.build(LTX23_ONE_STAGE_RECIPE.request_guidance)
perturbations = build_perturbation_kwargs(plan, batch_size=2, reference=torch.ones(8, 1, 1)) perturbations = build_perturbation_kwargs(plan, batch_size=2, reference=torch.ones(8, 1, 1))
@@ -768,19 +547,11 @@ def test_ltx_connector_attention_dispatches_through_omni_kernel(has_learned_regi
def test_ltx2_distilled_pipeline_shares_recipe_runtime(): def test_ltx2_distilled_pipeline_shares_recipe_runtime():
assert issubclass(LTX2DistilledPipeline, LTXI2VConditioningMixin) assert issubclass(LTX2DistilledPipeline, LTXI2VConditioningMixin)
assert not issubclass(LTX2DistilledPipeline, LTX2Pipeline) assert issubclass(LTX2DistilledPipeline, LTX2Pipeline)
assert LTX2DistilledPipeline._run_recipe is LTXRuntime._run_recipe assert LTX2DistilledPipeline._run_recipe is LTXRuntime._run_recipe
assert "_forward_request" not in LTX2DistilledPipeline.__dict__ assert "_forward_request" not in LTX2DistilledPipeline.__dict__
def test_ltx_two_stage_entry_is_a_thin_pipeline_variant():
assert "_run_recipe" not in LTX2TwoStagePipeline.__dict__
assert "_forward_request" not in LTX2TwoStagePipeline.__dict__
assert "load_weights" not in LTX2TwoStagePipeline.__dict__
assert issubclass(LTX2TwoStagePipeline, LTXI2VConditioningMixin)
assert not issubclass(LTX2TwoStagePipeline, LTX2Pipeline)
def test_ltx_variants_share_denoise_loop_and_i2v_conditioning(): def test_ltx_variants_share_denoise_loop_and_i2v_conditioning():
assert "_denoise_loop" not in LTX2Pipeline.__dict__ assert "_denoise_loop" not in LTX2Pipeline.__dict__
assert issubclass(LTX2Pipeline, LTXI2VConditioningMixin) assert issubclass(LTX2Pipeline, LTXI2VConditioningMixin)
@@ -823,8 +594,7 @@ def test_denoise_executor_owns_progress_and_interrupt():
torch.testing.assert_close(state.audio, torch.tensor(11.0)) torch.testing.assert_close(state.audio, torch.tensor(11.0))
@pytest.mark.parametrize("pipeline_cls", [LTX2TwoStagePipeline, LTX2DistilledPipeline]) def test_ltx2_distilled_two_stage_executes_declarative_phase_plan():
def test_ltx_two_stage_executes_declarative_i2v_phase_plan(pipeline_cls):
request_inputs = LTXRequestInputs( request_inputs = LTXRequestInputs(
prompt="prompt", prompt="prompt",
negative_prompt="", negative_prompt="",
@@ -901,15 +671,12 @@ def test_ltx_two_stage_executes_declarative_i2v_phase_plan(pipeline_cls):
return latents.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1) return latents.repeat_interleave(2, dim=-2).repeat_interleave(2, dim=-1)
prompt_context_sentinel = prompt_context prompt_context_sentinel = prompt_context
pipeline = object.__new__(pipeline_cls) pipeline = object.__new__(LTX2DistilledPipeline)
torch.nn.Module.__init__(pipeline) torch.nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu") pipeline.device = torch.device("cpu")
pipeline.vae_spatial_compression_ratio = 32 pipeline.vae_spatial_compression_ratio = 32
pipeline.vae_temporal_compression_ratio = 8 pipeline.vae_temporal_compression_ratio = 8
pipeline.latent_upsampler = FakeUpsampler() pipeline.latent_upsampler = FakeUpsampler()
activated_slots = []
if pipeline_cls is LTX2TwoStagePipeline:
pipeline._phase_adapter = SimpleNamespace(activate=activated_slots.append)
object.__setattr__(pipeline, "_resolve_request_inputs", resolve_request_inputs) object.__setattr__(pipeline, "_resolve_request_inputs", resolve_request_inputs)
object.__setattr__(pipeline, "run_phase", run_phase) object.__setattr__(pipeline, "run_phase", run_phase)
object.__setattr__(pipeline, "decode_phase", decode_phase) object.__setattr__(pipeline, "decode_phase", decode_phase)
@@ -919,18 +686,14 @@ def test_ltx_two_stage_executes_declarative_i2v_phase_plan(pipeline_cls):
assert len(phase_calls) == 2 assert len(phase_calls) == 2
assert phase_calls[1][2] is prompt_context_sentinel assert phase_calls[1][2] is prompt_context_sentinel
if pipeline_cls is LTX2TwoStagePipeline:
assert activated_slots == [None, "ltx_distilled"]
torch.testing.assert_close(output.output[0], torch.full((1, 128, 1, 2, 2), 3.0)) torch.testing.assert_close(output.output[0], torch.full((1, 128, 1, 2, 2), 3.0))
expected_audio = 2.0 if pipeline_cls is LTX2TwoStagePipeline else 4.0 torch.testing.assert_close(output.output[1], torch.full((1, 8, 1, 2), 4.0))
torch.testing.assert_close(output.output[1], torch.full((1, 8, 1, 2), expected_audio))
@pytest.mark.parametrize("pipeline_cls", [LTX2TwoStagePipeline, LTX2DistilledPipeline]) def test_ltx2_distilled_stage2_reapplies_final_resolution_i2v_conditioning():
def test_ltx_two_stage_stage2_reapplies_final_resolution_i2v_conditioning(pipeline_cls):
from vllm_omni.diffusion.models.ltx2 import ltx2_latents from vllm_omni.diffusion.models.ltx2 import ltx2_latents
pipeline = object.__new__(pipeline_cls) pipeline = object.__new__(LTX2DistilledPipeline)
torch.nn.Module.__init__(pipeline) torch.nn.Module.__init__(pipeline)
pipeline.vae_spatial_compression_ratio = 32 pipeline.vae_spatial_compression_ratio = 32
pipeline.vae_temporal_compression_ratio = 8 pipeline.vae_temporal_compression_ratio = 8
@@ -987,18 +750,6 @@ def test_ltx2_distilled_two_stage_recipe_is_fixed_positive_only():
assert (LTX2_DISTILLED_TWO_STAGE_RECIPE.height, LTX2_DISTILLED_TWO_STAGE_RECIPE.width) == (1024, 1536) assert (LTX2_DISTILLED_TWO_STAGE_RECIPE.height, LTX2_DISTILLED_TWO_STAGE_RECIPE.width) == (1024, 1536)
def test_ltx23_full_distilled_route_uses_merged_weights_without_lora():
assert resolve_ltx_component_profile("distilled_two_stage", "2.3") is LTX23_DISTILLED_COMPONENT_PROFILE
assert resolve_ltx_pipeline_recipe("distilled_two_stage", "2.3") is LTX23_DISTILLED_TWO_STAGE_RECIPE
assert LTX23_DISTILLED_TWO_STAGE_RECIPE is LTX2_DISTILLED_TWO_STAGE_RECIPE
assert all(phase.adapter_slot is None for phase in LTX23_DISTILLED_TWO_STAGE_RECIPE.phases)
assert LTX23_DISTILLED_COMPONENT_PROFILE.distilled_lora_filename is None
assert LTX23_DISTILLED_COMPONENT_PROFILE.vocoder_cls is LTX23_COMPONENT_PROFILE.vocoder_cls
assert LTX23_DISTILLED_COMPONENT_PROFILE.latent_upsampler_filename == (
"ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("request_inputs", "error"), ("request_inputs", "error"),
[ [
@@ -1207,98 +958,6 @@ def test_ltx_custom_sigmas_bypass_scheduler_shifting():
torch.testing.assert_close(timesteps, expected[:-1] * scheduler.config.num_train_timesteps) torch.testing.assert_close(timesteps, expected[:-1] * scheduler.config.num_train_timesteps)
@pytest.mark.parametrize(
("recipe", "steps", "stg_block"),
[
(LTX2_TWO_STAGE_RECIPE, 40, 29),
(LTX23_TWO_STAGE_RECIPE, 30, 28),
],
)
def test_ltx_official_two_stage_recipes_only_vary_by_model_defaults(recipe, steps, stg_block):
stage1, stage2 = recipe.phases
assert (recipe.width, recipe.height) == (1536, 1024)
assert recipe.num_inference_steps == steps
assert stage1.spatial_downscale == 2
assert stage1.adapter_slot is None
assert stage1.guidance.video.stg_blocks == (stg_block,)
assert stage1.guidance.audio.stg_blocks == (stg_block,)
assert stage2.input_transform == "spatial_upsample"
assert stage2.adapter_slot == "ltx_distilled"
assert stage2.guidance == LTXGuidanceSpec.positive_only()
assert stage2.num_inference_steps == 3
assert stage2.sigmas == (0.909375, 0.725, 0.421875, 0.0)
# Official Stage 2 refines video only; its audio result is discarded.
assert (recipe.video_output_phase, recipe.audio_output_phase) == (1, 0)
def test_ltx_two_stage_entries_select_the_official_adapter_slots():
phase_switches = []
ordinary_pipeline = object.__new__(LTX2TwoStagePipeline)
ordinary_pipeline._phase_adapter = SimpleNamespace(activate=phase_switches.append)
for phase in LTX2_TWO_STAGE_RECIPE.phases:
ordinary_pipeline._enter_phase(phase)
assert phase_switches == [None, "ltx_distilled"]
distilled_pipeline = object.__new__(LTX2DistilledPipeline)
for phase in LTX2_DISTILLED_TWO_STAGE_RECIPE.phases:
distilled_pipeline._enter_phase(phase)
def test_ltx23_gate_projection_declares_its_global_column_parallel_layout(monkeypatch):
class FakeColumn(torch.nn.Module):
def __init__(
self,
input_size,
output_size,
*,
bias,
gather_output,
return_bias,
quant_config,
prefix,
):
super().__init__()
self.input_size = input_size
self.output_size = output_size
self.bias_enabled = bias
self.gather_output = gather_output
self.return_bias = return_bias
self.quant_config = quant_config
self.prefix = prefix
class FakeRow(torch.nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
class FakeAttention(torch.nn.Module):
def __init__(self, **kwargs):
super().__init__()
monkeypatch.setattr(ltx2_transformer, "ColumnParallelLinear", FakeColumn)
monkeypatch.setattr(ltx2_transformer, "RowParallelLinear", FakeRow)
monkeypatch.setattr(ltx2_transformer, "_LTX2ParallelAttention", FakeAttention)
monkeypatch.setattr(ltx2_transformer, "get_tensor_model_parallel_world_size", lambda: 2)
attention = ltx2_transformer.LTX2Attention(
query_dim=16,
heads=4,
dim_head=4,
qk_norm="rms_norm",
apply_gated_attention=True,
pack_qkv=False,
prefix="transformer_blocks.0.attn1",
)
gate = attention.to_gate_logits
assert isinstance(gate, FakeColumn)
assert gate.input_size == 16
assert gate.output_size == 4
assert gate.gather_output is False
assert gate.return_bias is False
assert gate.quant_config is None
assert gate.prefix == "transformer_blocks.0.attn1.to_gate_logits"
class TestLTXRequestParsing: class TestLTXRequestParsing:
def test_request_resolves_official_and_explicit_negative_prompts_per_sample(self): def test_request_resolves_official_and_explicit_negative_prompts_per_sample(self):
from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.request import OmniDiffusionRequest
@@ -1773,7 +1432,6 @@ class TestPipelineComponents:
def test_distilled_profile_keeps_upsampler_in_managed_resident_components(self): def test_distilled_profile_keeps_upsampler_in_managed_resident_components(self):
assert LTX2_DISTILLED_COMPONENT_PROFILE.resident_modules == ("vocoder", "latent_upsampler") assert LTX2_DISTILLED_COMPONENT_PROFILE.resident_modules == ("vocoder", "latent_upsampler")
assert LTX23_DISTILLED_COMPONENT_PROFILE.resident_modules == ("vocoder", "latent_upsampler")
class TestLTX23DecodeConditioning: class TestLTX23DecodeConditioning:
@@ -1819,11 +1477,6 @@ class TestRegistryIntegration:
from vllm_omni.diffusion.registry import _DIFFUSION_MODELS from vllm_omni.diffusion.registry import _DIFFUSION_MODELS
assert _DIFFUSION_MODELS["LTX2Pipeline"] == ("ltx2", "pipeline_ltx2", "LTX2Pipeline") assert _DIFFUSION_MODELS["LTX2Pipeline"] == ("ltx2", "pipeline_ltx2", "LTX2Pipeline")
assert _DIFFUSION_MODELS["LTX2TwoStagePipeline"] == (
"ltx2",
"pipeline_ltx2_two_stage",
"LTX2TwoStagePipeline",
)
assert _DIFFUSION_MODELS["LTX2DistilledPipeline"] == ( assert _DIFFUSION_MODELS["LTX2DistilledPipeline"] == (
"ltx2", "ltx2",
"pipeline_ltx2_two_stage", "pipeline_ltx2_two_stage",
@@ -1835,9 +1488,6 @@ class TestRegistryIntegration:
"LTX23ImageToVideoPipeline", "LTX23ImageToVideoPipeline",
"LTX2TwoStagesPipeline", "LTX2TwoStagesPipeline",
"LTX2ImageToVideoTwoStagesPipeline", "LTX2ImageToVideoTwoStagesPipeline",
"LTX2ImageToVideoTwoStagePipeline",
"LTX23TwoStagePipeline",
"LTX23ImageToVideoTwoStagePipeline",
} }
assert removed_entries.isdisjoint(_DIFFUSION_MODELS) assert removed_entries.isdisjoint(_DIFFUSION_MODELS)
@@ -1847,7 +1497,6 @@ class TestRegistryIntegration:
expected = [ expected = [
"LTX2Pipeline", "LTX2Pipeline",
"LTX2TwoStagePipeline",
"LTX2DistilledPipeline", "LTX2DistilledPipeline",
"LTX2T2VDMD2Pipeline", "LTX2T2VDMD2Pipeline",
"LTX2I2VDMD2Pipeline", "LTX2I2VDMD2Pipeline",
@@ -1860,7 +1509,6 @@ class TestRegistryIntegration:
"model_class_name", "model_class_name",
[ [
"LTX2Pipeline", "LTX2Pipeline",
"LTX2TwoStagePipeline",
"LTX2DistilledPipeline", "LTX2DistilledPipeline",
"LTX2T2VDMD2Pipeline", "LTX2T2VDMD2Pipeline",
"LTX2I2VDMD2Pipeline", "LTX2I2VDMD2Pipeline",
@@ -1,20 +1,13 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest import pytest
import torch
from torch import nn from torch import nn
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import ( from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel, _make_rms_norm
LTX2AudioVideoAttnProcessor,
LTX2VideoTransformer3DModel, pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_LTX2ParallelAttention,
_make_rms_norm,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -73,83 +66,3 @@ def test_ltx_sp_plan_shards_video_and_audio_timesteps_together(rope_type):
assert video_timestep.expected_dims == audio_timestep.expected_dims == 2 assert video_timestep.expected_dims == audio_timestep.expected_dims == 2
assert not video_timestep.split_output assert not video_timestep.split_output
assert not audio_timestep.split_output assert not audio_timestep.split_output
def test_ltx_sp_keeps_cross_attention_key_padding_mask(monkeypatch):
monkeypatch.setattr(LTX2AudioVideoAttnProcessor, "_is_sp_enabled", staticmethod(lambda: True))
processor = LTX2AudioVideoAttnProcessor()
hidden_states = torch.zeros(1, 3, 4)
encoder_hidden_states = torch.zeros(1, 4, 4)
key_padding_mask = torch.tensor([[True, True, True, False]])
actual = processor._prepare_attention_mask(
SimpleNamespace(),
hidden_states,
encoder_hidden_states,
key_padding_mask,
batch_size=1,
sequence_length=4,
)
torch.testing.assert_close(actual, key_padding_mask[:, None, None, :])
def _fake_ltx_parallel_attention():
calls = []
class Backend:
@staticmethod
def get_name():
return "FLASH_ATTN"
@staticmethod
def supports_attention_mask():
return True
def run(name):
def forward(query, _key, _value, _metadata):
calls.append(name)
return torch.zeros_like(query)
return forward
layer = object.__new__(_LTX2ParallelAttention)
nn.Module.__init__(layer)
layer.attn_backend = Backend
layer.attention = SimpleNamespace(forward=run("native"))
layer.sdpa_fallback = SimpleNamespace(forward=run("sdpa"))
layer.backend_pref = "FLASH_ATTN"
return layer, calls
@pytest.mark.parametrize(("query_length", "key_length"), [(4, 4), (3, 4)])
def test_ltx_masked_cross_attention_uses_sdpa(query_length, key_length):
layer, calls = _fake_ltx_parallel_attention()
query = torch.zeros(1, query_length, 2, 4, dtype=torch.bfloat16)
key = value = torch.zeros(1, key_length, 2, 4, dtype=torch.bfloat16)
key_padding_mask = torch.tensor([[True] * (key_length - 1) + [False]])
metadata = AttentionMetadata(attn_mask=key_padding_mask[:, None, None, :])
layer._run_local_attention(query, key, value, metadata)
assert calls == ["sdpa"]
def test_ltx_masked_self_attention_uses_sdpa():
layer, calls = _fake_ltx_parallel_attention()
query = key = value = torch.zeros(1, 4, 2, 4, dtype=torch.bfloat16)
key_padding_mask = torch.tensor([[True, True, True, False]])
metadata = AttentionMetadata(attn_mask=key_padding_mask[:, None, None, :])
layer._run_local_attention(query, key, value, metadata)
assert calls == ["sdpa"]
def test_ltx_unmasked_cross_attention_keeps_native_backend():
layer, calls = _fake_ltx_parallel_attention()
query = key = value = torch.zeros(1, 4, 2, 4, dtype=torch.bfloat16)
layer._run_local_attention(query, key, value, None)
assert calls == ["native"]
+50 -117
View File
@@ -20,11 +20,6 @@ from PIL import Image
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--backend", choices=("official", "omni"), required=True) parser.add_argument("--backend", choices=("official", "omni"), required=True)
parser.add_argument(
"--pipeline-kind",
choices=("one_stage", "distilled", "two_stage"),
default="one_stage",
)
parser.add_argument("--request", type=Path, required=True) parser.add_argument("--request", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--model") parser.add_argument("--model")
@@ -32,8 +27,6 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--official-root", type=Path) parser.add_argument("--official-root", type=Path)
parser.add_argument("--checkpoint", type=Path) parser.add_argument("--checkpoint", type=Path)
parser.add_argument("--gemma-root", type=Path) parser.add_argument("--gemma-root", type=Path)
parser.add_argument("--spatial-upsampler", type=Path)
parser.add_argument("--distilled-lora", type=Path)
parser.add_argument("--enable-layerwise-offload", action="store_true") parser.add_argument("--enable-layerwise-offload", action="store_true")
return parser.parse_args() return parser.parse_args()
@@ -98,13 +91,9 @@ def _configure_official_sdpa(pipeline: Any) -> None:
attention=attention, attention=attention,
masked_attention=attention, masked_attention=attention,
) )
owners_and_attributes = [ owners_and_attributes = (
(pipeline.stage, "_transformer_builder"),
(pipeline.prompt_encoder, "_embeddings_processor_builder"), (pipeline.prompt_encoder, "_embeddings_processor_builder"),
]
owners_and_attributes.extend(
(stage, "_transformer_builder")
for name in ("stage", "stage_1", "stage_2")
if (stage := getattr(pipeline, name, None)) is not None
) )
for owner, attribute in owners_and_attributes: for owner, attribute in owners_and_attributes:
builder = getattr(owner, attribute) builder = getattr(owner, attribute)
@@ -115,12 +104,6 @@ def _configure_official_sdpa(pipeline: Any) -> None:
) )
def _require_path(path: Path | None, description: str) -> str:
if path is None or not path.is_file():
raise ValueError(f"Official {description} is required and must exist: {path}")
return str(path)
@torch.inference_mode() @torch.inference_mode()
def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None: def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
if args.official_root is None or args.checkpoint is None or args.gemma_root is None: if args.official_root is None or args.checkpoint is None or args.gemma_root is None:
@@ -130,54 +113,16 @@ def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
torch.backends.cuda.enable_cudnn_sdp(False) torch.backends.cuda.enable_cudnn_sdp(False)
from ltx_core.components.guiders import MultiModalGuiderParams from ltx_core.components.guiders import MultiModalGuiderParams
from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
from ltx_pipelines.distilled import DistilledPipeline
from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline from ltx_pipelines.ti2vid_one_stage import TI2VidOneStagePipeline
from ltx_pipelines.ti2vid_two_stages import TI2VidTwoStagesPipeline
from ltx_pipelines.utils.args import ImageConditioningInput from ltx_pipelines.utils.args import ImageConditioningInput
from ltx_pipelines.utils.types import OffloadMode from ltx_pipelines.utils.types import OffloadMode
if request.get("pipeline_kind", "one_stage") != args.pipeline_kind: pipeline = TI2VidOneStagePipeline(
raise ValueError( checkpoint_path=str(args.checkpoint),
f"Request pipeline kind {request.get('pipeline_kind')!r} does not match {args.pipeline_kind!r}" gemma_root=str(args.gemma_root),
) loras=(),
checkpoint = _require_path(args.checkpoint, "checkpoint") offload_mode=OffloadMode.CPU if args.enable_layerwise_offload else OffloadMode.NONE,
gemma_root = str(args.gemma_root) )
if not Path(gemma_root).is_dir():
raise ValueError(f"Official Gemma root is required and must be a directory: {gemma_root}")
offload_mode = OffloadMode.CPU if args.enable_layerwise_offload else OffloadMode.NONE
pipeline: Any
if args.pipeline_kind == "one_stage":
pipeline = TI2VidOneStagePipeline(
checkpoint_path=checkpoint,
gemma_root=gemma_root,
loras=(),
offload_mode=offload_mode,
)
elif args.pipeline_kind == "distilled":
pipeline = DistilledPipeline(
distilled_checkpoint_path=checkpoint,
spatial_upsampler_path=_require_path(args.spatial_upsampler, "spatial upsampler"),
gemma_root=gemma_root,
loras=(),
offload_mode=offload_mode,
)
else:
distilled_lora = [
LoraPathStrengthAndSDOps(
path=_require_path(args.distilled_lora, "distilled LoRA"),
strength=1.0,
sd_ops=LTXV_LORA_COMFY_RENAMING_MAP,
)
]
pipeline = TI2VidTwoStagesPipeline(
checkpoint_path=checkpoint,
distilled_lora=distilled_lora,
spatial_upsampler_path=_require_path(args.spatial_upsampler, "spatial upsampler"),
gemma_root=gemma_root,
loras=(),
offload_mode=offload_mode,
)
_configure_official_sdpa(pipeline) _configure_official_sdpa(pipeline)
image_path = request.get("image") image_path = request.get("image")
images = ( images = (
@@ -195,45 +140,34 @@ def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
) )
] ]
) )
if args.pipeline_kind == "distilled": video, audio = pipeline(
video, audio = pipeline( prompt=request["prompt"],
prompt=request["prompt"], negative_prompt=request["negative_prompt"],
seed=request["seed"], seed=request["seed"],
height=request["height"], height=request["height"],
width=request["width"], width=request["width"],
num_frames=request["num_frames"], num_frames=request["num_frames"],
frame_rate=request["fps"], frame_rate=request["fps"],
images=images, num_inference_steps=request["num_inference_steps"],
) video_guider_params=MultiModalGuiderParams(
else: cfg_scale=request["video_cfg_scale"],
video, audio = pipeline( stg_scale=request["video_stg_scale"],
prompt=request["prompt"], rescale_scale=request["video_rescale_scale"],
negative_prompt=request["negative_prompt"], modality_scale=request["video_modality_scale"],
seed=request["seed"], skip_step=0,
height=request["height"], stg_blocks=request["video_stg_blocks"],
width=request["width"], ),
num_frames=request["num_frames"], audio_guider_params=MultiModalGuiderParams(
frame_rate=request["fps"], cfg_scale=request["audio_cfg_scale"],
num_inference_steps=request["num_inference_steps"], stg_scale=request["audio_stg_scale"],
video_guider_params=MultiModalGuiderParams( rescale_scale=request["audio_rescale_scale"],
cfg_scale=request["video_cfg_scale"], modality_scale=request["audio_modality_scale"],
stg_scale=request["video_stg_scale"], skip_step=0,
rescale_scale=request["video_rescale_scale"], stg_blocks=request["audio_stg_blocks"],
modality_scale=request["video_modality_scale"], ),
skip_step=0, images=images,
stg_blocks=request["video_stg_blocks"], max_batch_size=4,
), )
audio_guider_params=MultiModalGuiderParams(
cfg_scale=request["audio_cfg_scale"],
stg_scale=request["audio_stg_scale"],
rescale_scale=request["audio_rescale_scale"],
modality_scale=request["audio_modality_scale"],
skip_step=0,
stg_blocks=request["audio_stg_blocks"],
),
images=images,
max_batch_size=4,
)
video_tensor = torch.cat([chunk.detach().cpu() for chunk in video], dim=0) video_tensor = torch.cat([chunk.detach().cpu() for chunk in video], dim=0)
_save_outputs( _save_outputs(
args.output_dir, args.output_dir,
@@ -245,7 +179,6 @@ def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None:
"attention_backend": "torch_sdpa", "attention_backend": "torch_sdpa",
"official_revision": os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION"), "official_revision": os.environ.get("VLLM_TEST_LTX_OFFICIAL_REVISION"),
"checkpoint": str(args.checkpoint), "checkpoint": str(args.checkpoint),
"pipeline_kind": args.pipeline_kind,
}, },
) )
@@ -260,9 +193,15 @@ def _unwrap_omni_output(output: Any) -> tuple[Any, Any, int]:
multimodal_output = frames.multimodal_output or {} multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio") audio = multimodal_output.get("audio")
audio_sample_rate = multimodal_output.get("audio_sample_rate") audio_sample_rate = multimodal_output.get("audio_sample_rate")
if not frames.images: if frames.is_pipeline_output and isinstance(frames.request_output, OmniRequestOutput):
raise ValueError("No video frames found in OmniRequestOutput") frames = frames.request_output
frames = frames.images multimodal_output = frames.multimodal_output or {}
audio = multimodal_output.get("audio", audio)
audio_sample_rate = multimodal_output.get("audio_sample_rate", audio_sample_rate)
if isinstance(frames, OmniRequestOutput):
if not frames.images:
raise ValueError("No video frames found in OmniRequestOutput")
frames = frames.images
if isinstance(frames, list) and len(frames) == 1: if isinstance(frames, list) and len(frames) == 1:
frames = frames[0] frames = frames[0]
@@ -314,10 +253,6 @@ def _canonical_video(video: Any) -> torch.Tensor:
def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None: def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
if args.model is None or args.model_class_name is None: if args.model is None or args.model_class_name is None:
raise ValueError("Omni backend requires --model and --model-class-name") raise ValueError("Omni backend requires --model and --model-class-name")
if request.get("pipeline_kind", "one_stage") != args.pipeline_kind:
raise ValueError(
f"Request pipeline kind {request.get('pipeline_kind')!r} does not match {args.pipeline_kind!r}"
)
attention_config = {"default": {"backend": "TORCH_SDPA"}} attention_config = {"default": {"backend": "TORCH_SDPA"}}
@@ -338,8 +273,7 @@ def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
parallel_config=DiffusionParallelConfig(), parallel_config=DiffusionParallelConfig(),
) )
try: try:
detected_model_class_name = get_model_class_name(omni) model_class_name = get_model_class_name(omni)
model_class_name = args.model_class_name
sampling_params = OmniDiffusionSamplingParams( sampling_params = OmniDiffusionSamplingParams(
height=request["height"], height=request["height"],
width=request["width"], width=request["width"],
@@ -355,9 +289,10 @@ def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
key: value for key, value in request.items() if key.startswith("video_") or key.startswith("audio_") key: value for key, value in request.items() if key.startswith("video_") or key.startswith("audio_")
} }
apply_declared_extra_args(sampling_params, get_extra_body_params(model_class_name), guidance) apply_declared_extra_args(sampling_params, get_extra_body_params(model_class_name), guidance)
prompt: dict[str, Any] = {"prompt": request["prompt"]} prompt: dict[str, Any] = {
if request.get("negative_prompt"): "prompt": request["prompt"],
prompt["negative_prompt"] = request["negative_prompt"] "negative_prompt": request["negative_prompt"],
}
image_path = request.get("image") image_path = request.get("image")
if image_path is not None: if image_path is not None:
with Image.open(str(image_path)) as source_image: with Image.open(str(image_path)) as source_image:
@@ -377,8 +312,6 @@ def _run_omni(args: argparse.Namespace, request: dict[str, Any]) -> None:
"attention_backend": "torch_sdpa", "attention_backend": "torch_sdpa",
"model": args.model, "model": args.model,
"model_class_name": model_class_name, "model_class_name": model_class_name,
"model_config_class_name": detected_model_class_name,
"pipeline_kind": args.pipeline_kind,
}, },
) )
finally: finally:
@@ -3,9 +3,10 @@
"""E2E accuracy guard against a pinned Lightricks LTX pipeline revision. """E2E accuracy guard against a pinned Lightricks LTX pipeline revision.
The original reduced one-stage guards remain unchanged. Four complementary The comparison runs both runtimes through PyTorch SDPA and uses
default-shape cases cover both model versions, both two-stage weight families, ``max_batch_size=4`` in the official reference to match Omni's fused guidance
and T2V/I2V without expanding every combination into a duplicate golden. batch. Video and audio guidance use the official non-HQ one-stage defaults;
only the generation shape and step count are reduced for CI runtime.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,9 +17,8 @@ import shutil
import subprocess import subprocess
import sys import sys
import time import time
from dataclasses import asdict, dataclass, replace from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal
import numpy as np import numpy as np
import pytest import pytest
@@ -53,260 +53,74 @@ NEGATIVE_PROMPT = (
# Both runtimes use PyTorch SDPA with the current Torch dispatch defaults. # Both runtimes use PyTorch SDPA with the current Torch dispatch defaults.
ATTENTION_BACKEND = "torch_sdpa" ATTENTION_BACKEND = "torch_sdpa"
VIDEO_SSIM_MEAN_THRESHOLD = 0.95
VIDEO_SSIM_MIN_THRESHOLD = 0.90
@dataclass(frozen=True) VIDEO_PSNR_MEAN_THRESHOLD = 30.0
class LTXArtifact: AUDIO_RELATIVE_L2_THRESHOLD = 0.2
repo_id: str AUDIO_COSINE_THRESHOLD = 0.95
filename: str
revision: str
env: str
repo_type: Literal["model", "dataset"] = "model"
@dataclass(frozen=True)
class LTXAccuracyThresholds:
video_ssim_mean: float
video_ssim_min: float
video_psnr_mean_db: float
# Zero disables gating for waveform-sensitive audio that is not expected
# to align with the official output.
audio_relative_l2: float
audio_cosine_similarity: float
STRICT_THRESHOLDS = LTXAccuracyThresholds(
video_ssim_mean=0.95,
video_ssim_min=0.90,
video_psnr_mean_db=30.0,
audio_relative_l2=0.20,
audio_cosine_similarity=0.95,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class LTXAccuracyCase: class LTXAccuracyCase:
name: str name: str
pipeline_kind: Literal["one_stage", "distilled", "two_stage"]
model_id: str model_id: str
model_revision: str model_revision: str
model_env: str model_env: str
model_class_name: str model_class_name: str
checkpoint: LTXArtifact checkpoint_repo: str
width: int checkpoint_filename: str
height: int checkpoint_revision: str
num_frames: int checkpoint_env: str
num_inference_steps: int stg_block: int
seed: int
stg_block: int | None
thresholds: LTXAccuracyThresholds
prompt: str = PROMPT prompt: str = PROMPT
gemma_model_id: str | None = None image_repo: str | None = None
gemma_model_revision: str | None = None image_filename: str | None = None
gemma_model_env: str | None = None image_revision: str | None = None
image: LTXArtifact | None = None
spatial_upsampler: LTXArtifact | None = None
distilled_lora: LTXArtifact | None = None
# Official uses block streaming; Omni uses layerwise offload. Both preserve
# bf16 arithmetic while keeping full-resolution two-stage cases on one H100.
enable_layerwise_offload: bool = False
LTX2_REVISION = "47da56e2ad66ce4125a9922b4a8826bf407f9d0a" CASES = (
LTX23_REVISION = "4229404625088d21c4f112eb640fb04a0900ee25"
LTX2_CHECKPOINT = LTXArtifact(
repo_id="Lightricks/LTX-2",
filename="ltx-2-19b-dev.safetensors",
revision=LTX2_REVISION,
env="VLLM_TEST_LTX2_OFFICIAL_CHECKPOINT",
)
LTX2_DISTILLED_CHECKPOINT = replace(
LTX2_CHECKPOINT,
filename="ltx-2-19b-distilled.safetensors",
env="VLLM_TEST_LTX2_DISTILLED_OFFICIAL_CHECKPOINT",
)
LTX2_UPSAMPLER = replace(
LTX2_CHECKPOINT,
filename="ltx-2-spatial-upscaler-x2-1.0.safetensors",
env="VLLM_TEST_LTX2_UPSAMPLER",
)
LTX2_DISTILLED_LORA = replace(
LTX2_CHECKPOINT,
filename="ltx-2-19b-distilled-lora-384.safetensors",
env="VLLM_TEST_LTX2_DISTILLED_LORA",
)
LTX23_CHECKPOINT = LTXArtifact(
repo_id="Lightricks/LTX-2.3",
filename="ltx-2.3-22b-dev.safetensors",
revision=LTX23_REVISION,
env="VLLM_TEST_LTX23_OFFICIAL_CHECKPOINT",
)
LTX23_DISTILLED_CHECKPOINT = replace(
LTX23_CHECKPOINT,
# The pinned Diffusers checkpoint contains the original merged distilled
# Transformer. Keep the reference Transformer on the same version; the
# spatial upsampler is versioned independently and remains on 1.1 below.
filename="ltx-2.3-22b-distilled.safetensors",
env="VLLM_TEST_LTX23_DISTILLED_OFFICIAL_CHECKPOINT",
)
LTX23_UPSAMPLER = replace(
LTX23_CHECKPOINT,
filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
env="VLLM_TEST_LTX23_UPSAMPLER",
)
LTX23_DISTILLED_LORA = replace(
LTX23_CHECKPOINT,
filename="ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
env="VLLM_TEST_LTX23_DISTILLED_LORA",
)
I2V_IMAGE = LTXArtifact(
repo_id="huggingface/documentation-images",
filename="diffusers/svd/rocket.png",
revision="645d8364f0c7a101180b364811b5a11a362e4010",
env="VLLM_TEST_LTX_I2V_IMAGE",
repo_type="dataset",
)
LEGACY_CASES = (
LTXAccuracyCase( LTXAccuracyCase(
name="ltx2", name="ltx2",
pipeline_kind="one_stage",
model_id="Lightricks/LTX-2", model_id="Lightricks/LTX-2",
model_revision=LTX2_REVISION, model_revision="47da56e2ad66ce4125a9922b4a8826bf407f9d0a",
model_env="VLLM_TEST_LTX2_MODEL", model_env="VLLM_TEST_LTX2_MODEL",
model_class_name="LTX2Pipeline", model_class_name="LTX2Pipeline",
checkpoint=LTX2_CHECKPOINT, checkpoint_repo="Lightricks/LTX-2",
width=512, checkpoint_filename="ltx-2-19b-dev.safetensors",
height=384, checkpoint_revision="47da56e2ad66ce4125a9922b4a8826bf407f9d0a",
num_frames=25, checkpoint_env="VLLM_TEST_LTX2_OFFICIAL_CHECKPOINT",
num_inference_steps=20,
seed=42,
stg_block=29, stg_block=29,
thresholds=STRICT_THRESHOLDS,
), ),
LTXAccuracyCase( LTXAccuracyCase(
name="ltx2_3", name="ltx2_3",
pipeline_kind="one_stage",
model_id="diffusers/LTX-2.3-Diffusers", model_id="diffusers/LTX-2.3-Diffusers",
model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf", model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
model_env="VLLM_TEST_LTX23_MODEL", model_env="VLLM_TEST_LTX23_MODEL",
model_class_name="LTX2Pipeline", model_class_name="LTX2Pipeline",
checkpoint=LTX23_CHECKPOINT, checkpoint_repo="Lightricks/LTX-2.3",
width=512, checkpoint_filename="ltx-2.3-22b-dev.safetensors",
height=384, checkpoint_revision="4229404625088d21c4f112eb640fb04a0900ee25",
num_frames=25, checkpoint_env="VLLM_TEST_LTX23_OFFICIAL_CHECKPOINT",
num_inference_steps=20,
seed=42,
stg_block=28, stg_block=28,
thresholds=STRICT_THRESHOLDS,
), ),
LTXAccuracyCase( LTXAccuracyCase(
name="ltx2_3_i2v", name="ltx2_3_i2v",
pipeline_kind="one_stage",
model_id="diffusers/LTX-2.3-Diffusers", model_id="diffusers/LTX-2.3-Diffusers",
model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf", model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
model_env="VLLM_TEST_LTX23_MODEL", model_env="VLLM_TEST_LTX23_MODEL",
model_class_name="LTX2Pipeline", model_class_name="LTX2Pipeline",
checkpoint=LTX23_CHECKPOINT, checkpoint_repo="Lightricks/LTX-2.3",
width=512, checkpoint_filename="ltx-2.3-22b-dev.safetensors",
height=384, checkpoint_revision="4229404625088d21c4f112eb640fb04a0900ee25",
num_frames=25, checkpoint_env="VLLM_TEST_LTX23_OFFICIAL_CHECKPOINT",
num_inference_steps=20,
seed=42,
stg_block=28, stg_block=28,
thresholds=STRICT_THRESHOLDS, image_repo="huggingface/documentation-images",
image=I2V_IMAGE, image_filename="diffusers/svd/rocket.png",
image_revision="645d8364f0c7a101180b364811b5a11a362e4010",
), ),
) )
TWO_STAGE_CASES = (
LTXAccuracyCase(
name="ltx2_distilled_t2v",
pipeline_kind="distilled",
model_id="rootonchair/LTX-2-19b-distilled",
model_revision="388e2846f54aae51687498ffb6b27c7c2c9ce9e5",
model_env="VLLM_TEST_LTX2_DISTILLED_MODEL",
model_class_name="LTX2DistilledPipeline",
checkpoint=LTX2_DISTILLED_CHECKPOINT,
spatial_upsampler=LTX2_UPSAMPLER,
width=1536,
height=1024,
num_frames=121,
num_inference_steps=8,
seed=10,
stg_block=None,
gemma_model_id="Lightricks/LTX-2",
gemma_model_revision=LTX2_REVISION,
gemma_model_env="VLLM_TEST_LTX2_MODEL",
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx23_distilled_i2v",
pipeline_kind="distilled",
model_id="diffusers/LTX-2.3-Distilled-Diffusers",
model_revision="432e0d3c2d1769aaa4d295f9243f7062bf6b47ee",
model_env="VLLM_TEST_LTX23_DISTILLED_MODEL",
model_class_name="LTX2DistilledPipeline",
checkpoint=LTX23_DISTILLED_CHECKPOINT,
spatial_upsampler=LTX23_UPSAMPLER,
width=1536,
height=1024,
num_frames=121,
num_inference_steps=8,
seed=10,
stg_block=None,
gemma_model_id="diffusers/LTX-2.3-Diffusers",
gemma_model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
gemma_model_env="VLLM_TEST_LTX23_MODEL",
thresholds=STRICT_THRESHOLDS,
image=I2V_IMAGE,
),
LTXAccuracyCase(
name="ltx2_two_stage_layer_fused_t2v",
pipeline_kind="two_stage",
model_id="Lightricks/LTX-2",
model_revision=LTX2_REVISION,
model_env="VLLM_TEST_LTX2_MODEL",
model_class_name="LTX2TwoStagePipeline",
checkpoint=LTX2_CHECKPOINT,
spatial_upsampler=LTX2_UPSAMPLER,
distilled_lora=LTX2_DISTILLED_LORA,
width=1536,
height=1024,
num_frames=121,
num_inference_steps=40,
seed=10,
stg_block=29,
enable_layerwise_offload=True,
thresholds=STRICT_THRESHOLDS,
),
LTXAccuracyCase(
name="ltx23_two_stage_layer_fused_i2v",
pipeline_kind="two_stage",
model_id="diffusers/LTX-2.3-Diffusers",
model_revision="8eee8edcf067e838b843f926ec4d4cc9b2be1aaf",
model_env="VLLM_TEST_LTX23_MODEL",
model_class_name="LTX2TwoStagePipeline",
checkpoint=LTX23_CHECKPOINT,
spatial_upsampler=LTX23_UPSAMPLER,
distilled_lora=LTX23_DISTILLED_LORA,
width=1536,
height=1024,
num_frames=121,
num_inference_steps=30,
seed=10,
stg_block=28,
enable_layerwise_offload=True,
thresholds=STRICT_THRESHOLDS,
image=I2V_IMAGE,
),
)
CASES = (*LEGACY_CASES, *TWO_STAGE_CASES)
def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None: def _run(command: list[str], *, env: dict[str, str], timeout: int = 1800) -> None:
start = time.perf_counter() start = time.perf_counter()
subprocess.run(command, env=env, timeout=timeout, check=True) subprocess.run(command, env=env, timeout=timeout, check=True)
@@ -395,7 +209,6 @@ def _resolve_model(case: LTXAccuracyCase) -> Path:
"model_index.json", "model_index.json",
"audio_vae/*", "audio_vae/*",
"connectors/*", "connectors/*",
"latent_upsampler/*",
"processor/*", "processor/*",
"scheduler/*", "scheduler/*",
"text_encoder/config.json", "text_encoder/config.json",
@@ -410,82 +223,74 @@ def _resolve_model(case: LTXAccuracyCase) -> Path:
) )
def _resolve_gemma_root(case: LTXAccuracyCase, model: Path) -> Path: def _resolve_gemma_root(model: Path) -> Path:
configured_root = os.environ.get("VLLM_TEST_LTX_GEMMA_ROOT") configured_root = os.environ.get("VLLM_TEST_LTX_GEMMA_ROOT")
if configured_root: if configured_root:
root = Path(configured_root) root = Path(configured_root)
assert root.is_dir(), f"Gemma root not found: {root}" assert root.is_dir(), f"Gemma root not found: {root}"
return root return root
if case.gemma_model_id is not None:
configured_model = os.environ.get(case.gemma_model_env or "")
if configured_model and Path(configured_model).is_dir():
return Path(configured_model)
return Path(
snapshot_download(
repo_id=case.gemma_model_id,
revision=case.gemma_model_revision,
allow_patterns=[
"processor/*",
"text_encoder/*",
"tokenizer/*",
],
)
)
return model return model
def _resolve_artifact(artifact: LTXArtifact, model: Path | None = None) -> Path: def _resolve_checkpoint(case: LTXAccuracyCase, model: Path) -> Path:
configured_path = os.environ.get(artifact.env) configured_checkpoint = os.environ.get(case.checkpoint_env)
if configured_path: if configured_checkpoint:
path = Path(configured_path) checkpoint = Path(configured_checkpoint)
assert path.is_file(), f"Configured LTX artifact not found: {path}" assert checkpoint.is_file(), f"Official checkpoint not found: {checkpoint}"
return path return checkpoint
if model is not None: model_checkpoint = model / case.checkpoint_filename
model_path = model / artifact.filename if model_checkpoint.is_file():
if model_path.is_file(): return model_checkpoint
return model_path
return Path( return Path(
hf_hub_download( hf_hub_download(
repo_id=artifact.repo_id, repo_id=case.checkpoint_repo,
repo_type=None if artifact.repo_type == "model" else artifact.repo_type, filename=case.checkpoint_filename,
filename=artifact.filename, revision=case.checkpoint_revision,
revision=artifact.revision,
) )
) )
def _resolve_image(case: LTXAccuracyCase) -> Path | None: def _resolve_image(case: LTXAccuracyCase) -> Path | None:
if case.image is None: if case.image_filename is None:
return None return None
return _resolve_artifact(case.image) if case.image_repo is None or case.image_revision is None:
raise ValueError(f"Incomplete image source for LTX accuracy case {case.name!r}.")
configured_image = os.environ.get("VLLM_TEST_LTX_I2V_IMAGE")
if configured_image:
image = Path(configured_image)
assert image.is_file(), f"LTX I2V conditioning image not found: {image}"
return image
return Path(
hf_hub_download(
repo_id=case.image_repo,
repo_type="dataset",
filename=case.image_filename,
revision=case.image_revision,
)
)
def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]: def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]:
request: dict[str, object] = { request: dict[str, object] = {
"pipeline_kind": case.pipeline_kind,
"prompt": case.prompt, "prompt": case.prompt,
"negative_prompt": "" if case.pipeline_kind == "distilled" else NEGATIVE_PROMPT, "negative_prompt": NEGATIVE_PROMPT,
"width": case.width, "width": 512,
"height": case.height, "height": 384,
"num_frames": case.num_frames, "num_frames": 25,
"fps": 24, "fps": 24,
"num_inference_steps": case.num_inference_steps, "num_inference_steps": 20,
"seed": case.seed, "seed": 42,
"video_cfg_scale": 3.0,
"audio_cfg_scale": 7.0,
"video_stg_scale": 1.0,
"audio_stg_scale": 1.0,
"video_modality_scale": 3.0,
"audio_modality_scale": 3.0,
"video_rescale_scale": 0.7,
"audio_rescale_scale": 0.7,
"video_stg_blocks": [case.stg_block],
"audio_stg_blocks": [case.stg_block],
} }
if case.pipeline_kind != "distilled":
assert case.stg_block is not None
request.update(
video_cfg_scale=3.0,
audio_cfg_scale=7.0,
video_stg_scale=1.0,
audio_stg_scale=1.0,
video_modality_scale=3.0,
audio_modality_scale=3.0,
video_rescale_scale=0.7,
audio_rescale_scale=0.7,
video_stg_blocks=[case.stg_block],
audio_stg_blocks=[case.stg_block],
)
if image is not None: if image is not None:
request["image"] = str(image.resolve()) request["image"] = str(image.resolve())
return request return request
@@ -494,30 +299,20 @@ def _request(case: LTXAccuracyCase, image: Path | None) -> dict[str, object]:
def _video_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float]: def _video_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, float]:
assert reference.shape == prediction.shape assert reference.shape == prediction.shape
assert reference.ndim == 4 and reference.shape[-1] == 3 assert reference.ndim == 4 and reference.shape[-1] == 3
device = torch.device("cpu")
ssim = StructuralSimilarityIndexMeasure(data_range=1.0).to(device)
psnr = PeakSignalNoiseRatio(data_range=1.0).to(device)
ssim_scores: list[float] = [] ssim_scores: list[float] = []
psnr_scores: list[float] = [] psnr_scores: list[float] = []
max_abs = 0.0 for reference_frame, prediction_frame in zip(reference, prediction, strict=True):
absolute_error_sum = 0.0 reference_tensor = torch.from_numpy(reference_frame).permute(2, 0, 1).unsqueeze(0)
with torch.inference_mode(): prediction_tensor = torch.from_numpy(prediction_frame).permute(2, 0, 1).unsqueeze(0)
for reference_frame, prediction_frame in zip(reference, prediction, strict=True): ssim_scores.append(float(StructuralSimilarityIndexMeasure(data_range=1.0)(prediction_tensor, reference_tensor)))
reference_tensor = torch.from_numpy(reference_frame).permute(2, 0, 1).unsqueeze(0).to(device) psnr_scores.append(float(PeakSignalNoiseRatio(data_range=1.0)(prediction_tensor, reference_tensor)))
prediction_tensor = torch.from_numpy(prediction_frame).permute(2, 0, 1).unsqueeze(0).to(device) difference = np.abs(reference.astype(np.float64) - prediction.astype(np.float64))
ssim_scores.append(float(ssim(prediction_tensor, reference_tensor)))
psnr_scores.append(float(psnr(prediction_tensor, reference_tensor)))
ssim.reset()
psnr.reset()
difference = np.abs(reference_frame.astype(np.float64) - prediction_frame.astype(np.float64))
max_abs = max(max_abs, float(difference.max()))
absolute_error_sum += float(difference.sum())
return { return {
"ssim_mean": float(np.mean(ssim_scores)), "ssim_mean": float(np.mean(ssim_scores)),
"ssim_min": float(np.min(ssim_scores)), "ssim_min": float(np.min(ssim_scores)),
"psnr_mean_db": float(np.mean(psnr_scores)), "psnr_mean_db": float(np.mean(psnr_scores)),
"max_abs": max_abs, "max_abs": float(difference.max()),
"mean_abs": absolute_error_sum / reference.size, "mean_abs": float(difference.mean()),
} }
@@ -548,19 +343,13 @@ def _audio_metrics(reference: np.ndarray, prediction: np.ndarray) -> dict[str, f
@pytest.mark.benchmark @pytest.mark.benchmark
@pytest.mark.diffusion @pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1) @hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Path) -> None: def test_ltx_one_stage_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Path) -> None:
"""Compare official and Omni raw AV outputs from the same single-H100 request.""" """Compare official and Omni raw AV outputs from the same E2E request."""
configured_artifact_root = os.environ.get("VLLM_TEST_LTX_ARTIFACT_ROOT")
if configured_artifact_root:
accuracy_artifact_root = Path(configured_artifact_root)
accuracy_artifact_root.mkdir(parents=True, exist_ok=True)
output_root = reset_artifact_dir(accuracy_artifact_root / "ltx_official" / case.name) output_root = reset_artifact_dir(accuracy_artifact_root / "ltx_official" / case.name)
official_root, official_revision = _official_source(accuracy_artifact_root / "ltx_official") official_root, official_revision = _official_source(accuracy_artifact_root / "ltx_official")
model = _resolve_model(case) model = _resolve_model(case)
gemma_root = _resolve_gemma_root(case, model) gemma_root = _resolve_gemma_root(model)
checkpoint = _resolve_artifact(case.checkpoint, model) checkpoint = _resolve_checkpoint(case, model)
spatial_upsampler = _resolve_artifact(case.spatial_upsampler, model) if case.spatial_upsampler is not None else None
distilled_lora = _resolve_artifact(case.distilled_lora, model) if case.distilled_lora is not None else None
image = _resolve_image(case) image = _resolve_image(case)
request_path = output_root / "request.json" request_path = output_root / "request.json"
request_path.write_text(json.dumps(_request(case, image), indent=2) + "\n") request_path.write_text(json.dumps(_request(case, image), indent=2) + "\n")
@@ -570,13 +359,8 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
str(runner), str(runner),
"--request", "--request",
str(request_path), str(request_path),
"--pipeline-kind",
case.pipeline_kind,
] ]
enable_layerwise_offload = case.enable_layerwise_offload or os.environ.get( if os.environ.get("VLLM_TEST_LTX_ENABLE_LAYERWISE_OFFLOAD", "").lower() in {"1", "true", "yes", "on"}:
"VLLM_TEST_LTX_ENABLE_LAYERWISE_OFFLOAD", ""
).lower() in {"1", "true", "yes", "on"}
if enable_layerwise_offload:
runner_args.append("--enable-layerwise-offload") runner_args.append("--enable-layerwise-offload")
env = os.environ.copy() env = os.environ.copy()
env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision env["VLLM_TEST_LTX_OFFICIAL_REVISION"] = official_revision
@@ -588,9 +372,10 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
) )
official_output = output_root / "official" official_output = output_root / "official"
official_args = _official_runner_prefix() + runner_args _run(
official_args.extend( _official_runner_prefix()
[ + runner_args
+ [
"--backend", "--backend",
"official", "official",
"--output-dir", "--output-dir",
@@ -601,16 +386,12 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
str(checkpoint), str(checkpoint),
"--gemma-root", "--gemma-root",
str(gemma_root), str(gemma_root),
] ],
env=env,
) )
if spatial_upsampler is not None:
official_args.extend(["--spatial-upsampler", str(spatial_upsampler)])
if distilled_lora is not None:
official_args.extend(["--distilled-lora", str(distilled_lora)])
_run(official_args, env=env)
omni_output = output_root / "omni" omni_output = output_root / "omni"
omni_args = ( _run(
[sys.executable] [sys.executable]
+ runner_args + runner_args
+ [ + [
@@ -622,9 +403,9 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
str(model), str(model),
"--model-class-name", "--model-class-name",
case.model_class_name, case.model_class_name,
] ],
env=env,
) )
_run(omni_args, env=env)
official_metadata = json.loads((official_output / "metadata.json").read_text()) official_metadata = json.loads((official_output / "metadata.json").read_text())
omni_metadata = json.loads((omni_output / "metadata.json").read_text()) omni_metadata = json.loads((omni_output / "metadata.json").read_text())
@@ -642,27 +423,18 @@ def test_ltx_matches_official(case: LTXAccuracyCase, accuracy_artifact_root: Pat
result = { result = {
"case": case.name, "case": case.name,
"task": "i2v" if image is not None else "t2v", "task": "i2v" if image is not None else "t2v",
"pipeline_kind": case.pipeline_kind,
"model_class_name": case.model_class_name,
"attention_backend": ATTENTION_BACKEND, "attention_backend": ATTENTION_BACKEND,
"official_revision": official_revision, "official_revision": official_revision,
"model_revision": case.model_revision, "model_revision": case.model_revision,
"checkpoint_revision": case.checkpoint.revision, "checkpoint_revision": case.checkpoint_revision,
"spatial_upsampler_revision": (case.spatial_upsampler.revision if case.spatial_upsampler is not None else None),
"distilled_lora_revision": case.distilled_lora.revision if case.distilled_lora is not None else None,
"enable_layerwise_offload": enable_layerwise_offload,
"thresholds": asdict(case.thresholds),
"request": _request(case, image),
"video": video_metrics, "video": video_metrics,
"audio": audio_metrics, "audio": audio_metrics,
} }
(output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n") (output_root / "metrics.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2)) print(json.dumps(result, indent=2))
assert video_metrics["ssim_mean"] >= case.thresholds.video_ssim_mean assert video_metrics["ssim_mean"] >= VIDEO_SSIM_MEAN_THRESHOLD
assert video_metrics["ssim_min"] >= case.thresholds.video_ssim_min assert video_metrics["ssim_min"] >= VIDEO_SSIM_MIN_THRESHOLD
assert video_metrics["psnr_mean_db"] >= case.thresholds.video_psnr_mean_db assert video_metrics["psnr_mean_db"] >= VIDEO_PSNR_MEAN_THRESHOLD
if case.thresholds.audio_relative_l2 > 0: assert audio_metrics["relative_l2"] <= AUDIO_RELATIVE_L2_THRESHOLD
assert audio_metrics["relative_l2"] <= case.thresholds.audio_relative_l2 assert audio_metrics["cosine_similarity"] >= AUDIO_COSINE_THRESHOLD
if case.thresholds.audio_cosine_similarity > 0:
assert audio_metrics["cosine_similarity"] >= case.thresholds.audio_cosine_similarity
-109
View File
@@ -1,109 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Online-serving smoke tests for the public LTX-2 pipeline entries."""
import os
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
DISTILLED_MODEL = os.getenv("VLLM_TEST_LTX23_DISTILLED_MODEL", "diffusers/LTX-2.3-Distilled-Diffusers")
BASE_MODEL = os.getenv("VLLM_TEST_LTX23_MODEL", "diffusers/LTX-2.3-Diffusers")
PROMPT = "A serene lake at sunset with mountains in the background."
SINGLE_CARD_MARKS = hardware_marks(res={"cuda": "H100"})
HSDP_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
def _server(
model: str,
model_class_name: str,
*extra_args: str,
) -> OmniServerParams:
return OmniServerParams(
model=model,
server_args=[
"--enforce-eager",
*extra_args,
"--model-class-name",
model_class_name,
],
)
def _cases():
return [
pytest.param(
_server(BASE_MODEL, "LTX2Pipeline"),
3,
id="one_stage",
marks=SINGLE_CARD_MARKS,
),
pytest.param(
_server(DISTILLED_MODEL, "LTX2DistilledPipeline"),
8,
id="distilled",
marks=SINGLE_CARD_MARKS,
),
pytest.param(
_server(
DISTILLED_MODEL,
"LTX2DistilledPipeline",
"--use-hsdp",
"--hsdp-shard-size",
"2",
),
8,
id="distilled_hsdp2",
marks=HSDP_MARKS,
),
pytest.param(
_server(
BASE_MODEL,
"LTX2TwoStagePipeline",
"--enable-layerwise-offload",
),
3,
id="two_stage",
marks=SINGLE_CARD_MARKS,
),
]
@pytest.mark.parametrize(
("omni_server", "num_inference_steps"),
_cases(),
indirect=["omni_server"],
)
def test_ltx_pipeline_entries(
omni_server: OmniServer,
num_inference_steps: int,
openai_client: OpenAIClientHandler,
subtests,
):
for task in ("t2v", "i2v"):
with subtests.test(task=task):
form_data = {
"prompt": PROMPT,
"model": omni_server.model,
"height": 128,
"width": 128,
"num_frames": 9,
"fps": 8,
"num_inference_steps": num_inference_steps,
"seed": 42,
}
request_config = {
"model": omni_server.model,
"form_data": form_data,
}
if task == "i2v":
request_config["image_reference"] = (
f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
)
openai_client.send_video_diffusion_request(request_config)
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Expansion tests for LTX-2 two-stage pipelines.
Coverage:
- HSDP with LTX2DistilledPipeline (text-to-video and image-to-video)
"""
import os
import pytest
from tests.helpers.mark import hardware_marks
from tests.helpers.media import generate_synthetic_image
from tests.helpers.runtime import OmniServer, OmniServerParams, OpenAIClientHandler
pytestmark = [pytest.mark.diffusion, pytest.mark.full_model]
MODEL = os.getenv("VLLM_TEST_LTX2_MODEL", "rootonchair/LTX-2-19b-distilled")
PROMPT = "A serene lake at sunset with mountains in the background."
PARALLEL_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2)
HSDP_ARGS = ["--use-hsdp", "--hsdp-shard-size", "2"]
def _cases():
cases = []
cases.append(
pytest.param(
OmniServerParams(
model=MODEL,
server_args=[
*HSDP_ARGS,
"--model-class-name",
"LTX2DistilledPipeline",
],
),
False,
id="t2v_hsdp",
marks=PARALLEL_MARKS,
)
)
cases.append(
pytest.param(
OmniServerParams(
model=MODEL,
server_args=[
*HSDP_ARGS,
"--model-class-name",
"LTX2DistilledPipeline",
],
),
True,
id="i2v_hsdp",
marks=PARALLEL_MARKS,
)
)
return cases
@pytest.mark.parametrize(("omni_server", "is_i2v"), _cases(), indirect=["omni_server"])
def test_ltx2_two_stage_hsdp(
omni_server: OmniServer,
is_i2v: bool,
openai_client: OpenAIClientHandler,
):
# Keep CI small while exercising the fixed 8+3-step distilled recipe.
# Height and width describe the final output; Stage 1 runs at half size.
form_data = {
"prompt": PROMPT,
"model": omni_server.model,
"height": 128,
"width": 128,
"num_frames": 9,
"fps": 8,
"num_inference_steps": 8,
"seed": 42,
}
request_config = {
"model": omni_server.model,
"form_data": form_data,
}
if is_i2v:
request_config["image_reference"] = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}"
openai_client.send_video_diffusion_request(request_config)
-2
View File
@@ -139,7 +139,6 @@ def _engine_arg_inputs(tmp_path: Path) -> tuple[PipelineConfig, DeployConfig, st
engine_extras={ engine_extras={
"model": str(stage_model), "model": str(stage_model),
"attention_backend": "FLASHINFER", "attention_backend": "FLASHINFER",
"attention_config": {"use_trtllm_attention": False},
"hf_overrides": {"rope_scaling": {"factor": 2}}, "hf_overrides": {"rope_scaling": {"factor": 2}},
"limit_mm_per_prompt": {"audio": 1}, "limit_mm_per_prompt": {"audio": 1},
"logits_processors": ["test.custom.LogitsProcessor"], "logits_processors": ["test.custom.LogitsProcessor"],
@@ -230,7 +229,6 @@ def test_typed_llm_engine_args_preserve_legacy_adapter_behavior(tmp_path):
assert thinker_args["omni_kv_config"] == {"need_send_cache": True} assert thinker_args["omni_kv_config"] == {"need_send_cache": True}
assert thinker_args["worker_cls"] == "test.custom.Worker" assert thinker_args["worker_cls"] == "test.custom.Worker"
assert thinker_args["attention_backend"] == "FLASHINFER" assert thinker_args["attention_backend"] == "FLASHINFER"
assert thinker_args["attention_config"] == {"use_trtllm_attention": False}
assert thinker_args["trust_remote_code"] is True assert thinker_args["trust_remote_code"] is True
assert thinker_args["dtype"] == "float16" assert thinker_args["dtype"] == "float16"
assert thinker_args["distributed_executor_backend"] == "mp" assert thinker_args["distributed_executor_backend"] == "mp"
+12
View File
@@ -76,6 +76,18 @@ def test_serve_parser_accepts_four_way_cfg_parallelism() -> None:
assert args.cfg_parallel_size == 4 assert args.cfg_parallel_size == 4
def test_serve_parser_defaults_vae_parallel_mode_to_tile_and_accepts_auto() -> None:
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="subcommand")
OmniServeCommand().subparser_init(subparsers)
default_args = parser.parse_args(["serve", "fake-model", "--omni"])
auto_args = parser.parse_args(["serve", "fake-model", "--omni", "--vae-parallel-mode", "auto"])
assert default_args.vae_parallel_mode == "tile"
assert auto_args.vae_parallel_mode == "auto"
def _make_headless_args(**kwargs) -> TrackingNamespace: def _make_headless_args(**kwargs) -> TrackingNamespace:
defaults = { defaults = {
"model": "fake-model", "model": "fake-model",
@@ -1,2 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
@@ -1,137 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for the dots.tts talker's stop-signal pairing and
per-request state lifecycle.
Pure-CPU tests on a bare talker (``__new__`` + the handful of attributes
the methods under test read) — no weights, no GPU, no engine. Mirrors
tests/model_executor/models/voxcpm2/test_talker_state_eviction.py.
"""
from __future__ import annotations
import functools
from types import SimpleNamespace
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
torch = pytest.importorskip("torch")
_VOCAB_SIZE = 151672
@functools.lru_cache(maxsize=1)
def _dots_tts_talker_mod():
"""Defer talker import (pulls vLLM model_executor) until first use."""
from vllm_omni.model_executor.models.dots_tts.dots_tts_talker import (
DotsTTSForConditionalGeneration,
_RequestState,
)
return DotsTTSForConditionalGeneration, _RequestState
def _make_bare_talker():
DotsTTSForConditionalGeneration, _ = _dots_tts_talker_mod()
talker = DotsTTSForConditionalGeneration.__new__(DotsTTSForConditionalGeneration)
talker.config = SimpleNamespace(vocab_size=_VOCAB_SIZE)
talker._active_states = {}
talker._pending_requests = []
talker._results_queue = []
talker._audio_queue = []
talker._deferred_cleanup_ids = set()
talker._beta_trace = False
return talker
def _add_state(talker, req_id: str, *, is_stopping: bool = False):
_, _RequestState = _dots_tts_talker_mod()
state = _RequestState(request_id=req_id)
state.prefill_completed = True
state.is_stopping = is_stopping
talker._active_states[req_id] = state
return state
def _hidden(bsz: int) -> torch.Tensor:
return torch.zeros(bsz, 1536, dtype=torch.float32)
class TestComputeLogitsPairing:
"""compute_logits pairs _results_queue entries with batch rows
positionally, so every request must push exactly one entry per step —
including prefills, which push a (req_id, None) placeholder."""
def test_mixed_batch_prefill_placeholder_keeps_rows_aligned(self) -> None:
talker = _make_bare_talker()
_add_state(talker, "prefill-req")
decode_state = _add_state(talker, "decode-req", is_stopping=True)
decode_state.precomputed_stop_logits = torch.tensor([[0.1, 0.9]])
# Row 0 is the prefill (None placeholder), row 1 the stopping decode.
talker._results_queue = [
("prefill-req", None),
("decode-req", torch.tensor([[0.1, 0.9]])),
]
logits = talker.compute_logits(_hidden(2))
assert logits.shape == (2, _VOCAB_SIZE)
# Prefill row: forced continue.
assert logits[0, 0].item() == 1.0
assert logits[0, 1].item() == float("-inf")
# Stopping decode row: stop slot wins.
assert logits[1, 1].item() == 1.0
assert logits[1, 0].item() == 0.0
# Everything else stays masked.
assert logits[0, 2].item() == float("-inf")
assert logits[1, 2].item() == float("-inf")
# Queue drained.
assert talker._results_queue == []
def test_below_threshold_stop_logits_force_continue(self) -> None:
"""Raw (continue, stop) softmax must not reach the sampler: a
prob_stop in (0.5, 0.8] would flip greedy argmax even though
is_stopping stayed False (the 0.8-threshold contract)."""
talker = _make_bare_talker()
_add_state(talker, "req", is_stopping=False)
talker._results_queue = [("req", torch.tensor([[0.3, 0.7]]))]
logits = talker.compute_logits(_hidden(1))
assert logits[0, 0].item() == 1.0
assert logits[0, 1].item() == float("-inf")
def test_empty_queue_defaults_to_all_continue(self) -> None:
talker = _make_bare_talker()
logits = talker.compute_logits(_hidden(3))
assert torch.all(logits[:, 0] == 1.0)
assert torch.all(logits[:, 1] == float("-inf"))
class TestStateEviction:
"""on_requests_finished fires BEFORE forward(), so eviction must be
deferred to _flush_deferred_cleanup at the end of the step — the
finishing request's audio still drains through the current forward."""
def test_finished_request_survives_until_flush(self) -> None:
talker = _make_bare_talker()
_add_state(talker, "req-a")
_add_state(talker, "req-b")
talker.on_requests_finished(["req-a"])
assert "req-a" in talker._active_states # not evicted yet
talker._flush_deferred_cleanup()
assert "req-a" not in talker._active_states
assert "req-b" in talker._active_states
assert talker._deferred_cleanup_ids == set()
def test_unknown_request_id_is_ignored(self) -> None:
talker = _make_bare_talker()
talker.on_requests_finished(["never-seen"])
talker._flush_deferred_cleanup()
assert talker._active_states == {}
@@ -1326,9 +1326,7 @@ class TestRegistry:
assert "higgs_multimodal_qwen3" in OMNI_PIPELINES assert "higgs_multimodal_qwen3" in OMNI_PIPELINES
def test_deploy_yaml_uses_native_flashinfer(self): def test_deploy_yaml_exists(self):
from vllm_omni.config.stage_config import load_deploy_config
for name in ( for name in (
"higgs_multimodal_qwen3.yaml", "higgs_multimodal_qwen3.yaml",
"higgs_multimodal_qwen3_high_throughput.yaml", "higgs_multimodal_qwen3_high_throughput.yaml",
@@ -1336,10 +1334,6 @@ class TestRegistry:
): ):
yaml_path = get_deploy_config_path(name) yaml_path = get_deploy_config_path(name)
assert os.path.isfile(yaml_path), f"Deploy YAML not found at {yaml_path}" assert os.path.isfile(yaml_path), f"Deploy YAML not found at {yaml_path}"
deploy_config = load_deploy_config(yaml_path)
stage_0 = next(stage for stage in deploy_config.stages if stage.stage_id == 0)
assert stage_0.engine_extras["attention_backend"] == "FLASHINFER"
assert stage_0.engine_extras["attention_config"] == {"use_trtllm_attention": False}
# ---- AC-3: Prompt Builder ---- # ---- AC-3: Prompt Builder ----
+4 -7
View File
@@ -233,13 +233,10 @@ def test_ltx_extra_registry_declares_official_guidance_params() -> None:
} }
) )
for pipeline_name in ( assert get_extra_body_params("LTX2Pipeline") == expected
"LTX2Pipeline", assert get_extra_output_params("LTX2Pipeline") == frozenset()
"LTX2TwoStagePipeline", assert get_extra_body_params("LTX2DistilledPipeline") == expected
"LTX2DistilledPipeline", assert get_extra_output_params("LTX2DistilledPipeline") == frozenset()
):
assert get_extra_body_params(pipeline_name) == expected
assert get_extra_output_params(pipeline_name) == frozenset()
@pytest.mark.core_model @pytest.mark.core_model
@@ -27,7 +27,6 @@ EXCLUDED_MODELS = [
"OvisImagePipeline", "OvisImagePipeline",
"WanPipeline", "WanPipeline",
"WanVACEPipeline", "WanVACEPipeline",
"LTX2TwoStagePipeline",
"LTX2DistilledPipeline", "LTX2DistilledPipeline",
"LTX2T2VDMD2Pipeline", "LTX2T2VDMD2Pipeline",
"LTX2I2VDMD2Pipeline", "LTX2I2VDMD2Pipeline",
+3 -4
View File
@@ -90,7 +90,6 @@ class _ModelEngineOverrides(TypedDict, total=False):
trust_remote_code: bool trust_remote_code: bool
dtype: Any dtype: Any
attention_backend: Any attention_backend: Any
attention_config: Any
moe_backend: str moe_backend: str
hf_overrides: Any hf_overrides: Any
limit_mm_per_prompt: dict[str, Any] limit_mm_per_prompt: dict[str, Any]
@@ -322,7 +321,6 @@ class OmniStageModelConfig:
trust_remote_code: bool = False trust_remote_code: bool = False
dtype: Any = "auto" dtype: Any = "auto"
attention_backend: Any = None attention_backend: Any = None
attention_config: Any = None
moe_backend: str = "auto" moe_backend: str = "auto"
hf_overrides: Any = None hf_overrides: Any = None
limit_mm_per_prompt: dict[str, Any] | None = None limit_mm_per_prompt: dict[str, Any] | None = None
@@ -475,9 +473,10 @@ class OmniStageDiffusionParallelConfig(OmniStageParallelConfig):
raise ValueError("allgather_degree > 1 is mutually exclusive with ulysses_degree/ring_degree > 1") raise ValueError("allgather_degree > 1 is mutually exclusive with ulysses_degree/ring_degree > 1")
if self.ulysses_mode not in {"strict", "advanced_uaa"}: if self.ulysses_mode not in {"strict", "advanced_uaa"}:
raise ValueError("ulysses_mode must be 'strict' or 'advanced_uaa'") raise ValueError("ulysses_mode must be 'strict' or 'advanced_uaa'")
if self.vae_parallel_mode not in {"tile", "spatial_shard_height", "spatial_shard_width"}: if self.vae_parallel_mode not in {"tile", "auto", "spatial_shard_height", "spatial_shard_width"}:
raise ValueError( raise ValueError(
"vae_parallel_mode must be one of {'tile', 'spatial_shard_height', 'spatial_shard_width'}, " "vae_parallel_mode must be one of "
"{'tile', 'auto', 'spatial_shard_height', 'spatial_shard_width'}, "
f"but got {self.vae_parallel_mode!r}." f"but got {self.vae_parallel_mode!r}."
) )
-2
View File
@@ -47,7 +47,6 @@ from vllm_omni.model_executor.models.bagel.pipeline import (
) )
from vllm_omni.model_executor.models.cosyvoice3.pipeline import COSYVOICE3_PIPELINE from vllm_omni.model_executor.models.cosyvoice3.pipeline import COSYVOICE3_PIPELINE
from vllm_omni.model_executor.models.covo_audio.pipeline import COVO_AUDIO_PIPELINE from vllm_omni.model_executor.models.covo_audio.pipeline import COVO_AUDIO_PIPELINE
from vllm_omni.model_executor.models.dots_tts.pipeline import DOTS_TTS_PIPELINE
from vllm_omni.model_executor.models.dreamzero.pipeline import DREAMZERO_PIPELINE from vllm_omni.model_executor.models.dreamzero.pipeline import DREAMZERO_PIPELINE
from vllm_omni.model_executor.models.dynin_omni.pipeline import DYNIN_OMNI_PIPELINE from vllm_omni.model_executor.models.dynin_omni.pipeline import DYNIN_OMNI_PIPELINE
from vllm_omni.model_executor.models.fish_speech.pipeline import FISH_SPEECH_PIPELINE from vllm_omni.model_executor.models.fish_speech.pipeline import FISH_SPEECH_PIPELINE
@@ -145,7 +144,6 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"hunyuan_video_15": HUNYUAN_VIDEO_15_PIPELINE, "hunyuan_video_15": HUNYUAN_VIDEO_15_PIPELINE,
"wan2_2_ti2v": WAN2_2_TI2V_PIPELINE, "wan2_2_ti2v": WAN2_2_TI2V_PIPELINE,
"voxcpm2": VOXCPM2_PIPELINE, "voxcpm2": VOXCPM2_PIPELINE,
"dots_tts": DOTS_TTS_PIPELINE,
"cosyvoice3": COSYVOICE3_PIPELINE, "cosyvoice3": COSYVOICE3_PIPELINE,
"audex_tts": AUDEX_TTS_PIPELINE, "audex_tts": AUDEX_TTS_PIPELINE,
"audex_tta": AUDEX_TTA_PIPELINE, "audex_tta": AUDEX_TTA_PIPELINE,
-59
View File
@@ -44,65 +44,6 @@ vllm-omni serve bosonai/higgs-audio-v3-tts-4b \
--deploy-config vllm_omni/deploy/higgs_multimodal_qwen3_low_latency.yaml --deploy-config vllm_omni/deploy/higgs_multimodal_qwen3_low_latency.yaml
``` ```
## Why Stage 0 Pins Native FlashInfer
The three Stage 0 profiles set `attention_backend: FLASHINFER` and also set:
```yaml
attention_config:
use_trtllm_attention: false
```
`FLASHINFER` names the overall vLLM attention backend. Starting with vLLM
0.25, that backend can internally route Hopper decode to the TRT-LLM XQA
kernel. The XQA eligibility check answers whether the kernel can run, not
whether it is the fastest kernel for the current workload. In particular, the
decode builder can select XQA statically when all of the following hold:
- the explicit `use_trtllm_attention` override is not `false`;
- the device is Hopper/SM90 and the XQA cubin is available; and
- the query-head count is divisible by the KV-head count.
Higgs has 32 query heads and 8 KV heads, so it passes the head-layout check.
The static gate does not include a model dtype, KV-cache dtype, context-length,
batch-shape, or measured-performance comparison. This matters because kernel
support is not a performance guarantee.
Higgs also has an unusual output shape that must not be confused with its
attention shape. One autoregressive audio step advances one token per request.
The resulting hidden row is then projected into eight codebook distributions
with shape `[1, 8, codebook_size]`. The eight codebooks therefore do not turn
the attention query into `q_len=8`; at concurrency one, XQA still receives a
single BF16 query row. At this small per-step shape, any XQA advantage for
other KV dtypes, batch sizes, or context lengths does not necessarily apply.
The difference is paid in every transformer layer and every generated audio
step, so even a small per-call disadvantage accumulates in TTFP and RTF.
The upstream XQA integration also showed that the result is dtype-sensitive:
its FP8 benchmark favored XQA, while its BF16 benchmark reported 21.16 ms mean
TPOT for XQA versus 19.47 ms for FA3. That is not a direct native-FlashInfer
comparison, but it demonstrates why SM90 compatibility alone is insufficient
to choose the fastest BF16 kernel. See the
[vLLM XQA benchmark](https://github.com/vllm-project/vllm/pull/43232#issuecomment-4501176335).
The end-to-end H800 A/B in
[issue #5584](https://github.com/vllm-project/vllm-omni/issues/5584#issuecomment-5188700218)
isolated the routing decision on the same vLLM 0.25 stack:
| Decode path | TTFP (ms) | RTF |
|---|---:|---:|
| XQA (automatic default) | 175.76 | 0.3405 |
| Native FlashInfer (`use_trtllm_attention: false`) | 166.39 | 0.3233 |
| vLLM 0.24 reference | 166.68 | 0.3209 |
This proves that XQA selection causes the observed local regression and that
native FlashInfer recovers the pre-v0.25 range. It does not by itself identify
one defective XQA instruction or memory transaction. Establishing that deeper
kernel-level cause requires an attention-only microbenchmark and GPU profiler
trace for the exact Higgs batch/context distribution. Until vLLM has a
workload-aware selector or XQA is faster for this BF16 shape, the model-level
pin is the deterministic choice.
## Notes ## Notes
- Stage 1 remains `enforce_eager: true` in both profiles. - Stage 1 remains `enforce_eager: true` in both profiles.
-28
View File
@@ -1,28 +0,0 @@
# dots.tts deployment config (single-stage AR talker).
#
# Conservative first-integration settings: eager execution, no CUDA
# Graphs, no batched side-path optimizations yet — see voxcpm2.yaml for
# the runtime knobs a mature deployment of this model family grows.
#
# Verified locally on RTX 5080 16GB.
async_chunk: false
dtype: bfloat16
stages:
- stage_id: 0
max_num_seqs: 4
gpu_memory_utilization: 0.8
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
async_scheduling: true
max_num_batched_tokens: 4096
max_model_len: 4096
devices: "0"
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 4096
repetition_penalty: 1.0
@@ -38,11 +38,6 @@ stages:
gpu_memory_utilization: 0.6 gpu_memory_utilization: 0.6
enforce_eager: true enforce_eager: true
attention_backend: FLASHINFER attention_backend: FLASHINFER
# Eight codebooks share one attention row, so decode remains BF16 q_len=1
# per request. vLLM's SM90 XQA capability gate does not benchmark this
# shape; keep the faster native path (README_higgs_audio_v3.md, #5584).
attention_config:
use_trtllm_attention: false
trust_remote_code: true trust_remote_code: true
# Stage-0 prefix caching is on by default. The talker's HS opt-out and # Stage-0 prefix caching is on by default. The talker's HS opt-out and
# deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py) # deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py)
@@ -37,11 +37,6 @@ stages:
gpu_memory_utilization: 0.6 gpu_memory_utilization: 0.6
enforce_eager: true enforce_eager: true
attention_backend: FLASHINFER attention_backend: FLASHINFER
# Eight codebooks share one attention row, so decode remains BF16 q_len=1
# per request. vLLM's SM90 XQA capability gate does not benchmark this
# shape; keep the faster native path (README_higgs_audio_v3.md, #5584).
attention_config:
use_trtllm_attention: false
trust_remote_code: true trust_remote_code: true
# Stage-0 prefix caching is on by default. The talker's HS opt-out and # Stage-0 prefix caching is on by default. The talker's HS opt-out and
# deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py) # deferred ``codes.audio`` mm-output write (see higgs_audio_v3_talker.py)
@@ -43,11 +43,6 @@ stages:
gpu_memory_utilization: 0.6 gpu_memory_utilization: 0.6
enforce_eager: false enforce_eager: false
attention_backend: FLASHINFER attention_backend: FLASHINFER
# Eight codebooks share one attention row, so decode remains BF16 q_len=1
# per request. vLLM's SM90 XQA capability gate does not benchmark this
# shape; keep the faster native path (README_higgs_audio_v3.md, #5584).
attention_config:
use_trtllm_attention: false
trust_remote_code: true trust_remote_code: true
enable_prefix_caching: true enable_prefix_caching: true
async_scheduling: false async_scheduling: false
+3 -4
View File
@@ -1263,7 +1263,6 @@ def extract_minimax_h3_context(
_BF16_DTYPE, _BF16_DTYPE,
_FORWARD_SUPPORTED_KWARGS, _FORWARD_SUPPORTED_KWARGS,
MINIMAX_H3_ADALN_MODALITY_NUM, MINIMAX_H3_ADALN_MODALITY_NUM,
_build_rope_table,
_modulate_scale_shift, _modulate_scale_shift,
_required_kwarg, _required_kwarg,
) )
@@ -1319,7 +1318,7 @@ def extract_minimax_h3_context(
raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}") raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}")
device = x.device device = x.device
rope_table = _build_rope_table(module.rope(img_position_ids).to(device)) rope_freqs = module.rope(img_position_ids).to(device)
decoder_input, t_emb = module._embed( decoder_input, t_emb = module._embed(
x=x, x=x,
@@ -1352,7 +1351,7 @@ def extract_minimax_h3_context(
def run_transformer_blocks() -> tuple[torch.Tensor, ...]: def run_transformer_blocks() -> tuple[torch.Tensor, ...]:
hidden, block_rope, block_combined = module.sp_prepare( hidden, block_rope, block_combined = module.sp_prepare(
decoder_input, decoder_input,
rope_table, rope_freqs,
combined_indices, combined_indices,
) )
for block in module.blocks: for block in module.blocks:
@@ -1360,7 +1359,7 @@ def extract_minimax_h3_context(
hidden, hidden,
t_emb=t_emb, t_emb=t_emb,
combined_indices=block_combined, combined_indices=block_combined,
rope_table=block_rope, rope_freqs=block_rope,
cu_seqlens=cu_seqlens, cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
packed_total=seq_len, packed_total=seq_len,
+6 -2
View File
@@ -212,6 +212,9 @@ class DiffusionParallelConfig:
- "tile": Patch/tile parallel decode (default). Each rank decodes a subset - "tile": Patch/tile parallel decode (default). Each rank decodes a subset
of spatial tiles and the results are stitched on rank 0. of spatial tiles and the results are stitched on rank 0.
- "auto": Select the longer spatial-shard axis for validated Wan tiled
decodes on a full DiT-group topology; otherwise use patch/tile parallel
decode.
- "spatial_shard_height": Spatially-sharded decode that splits decoder - "spatial_shard_height": Spatially-sharded decode that splits decoder
feature maps along height and exchanges halo rows around spatial feature maps along height and exchanges halo rows around spatial
convolutions. convolutions.
@@ -251,8 +254,9 @@ class DiffusionParallelConfig:
assert self.allgather_degree > 0, "AllGather degree must be > 0" assert self.allgather_degree > 0, "AllGather degree must be > 0"
assert self.cfg_parallel_size > 0, "CFG parallel size must be > 0" assert self.cfg_parallel_size > 0, "CFG parallel size must be > 0"
assert self.vae_patch_parallel_size > 0, "VAE patch parallel size must be > 0" assert self.vae_patch_parallel_size > 0, "VAE patch parallel size must be > 0"
assert self.vae_parallel_mode in {"tile", "spatial_shard_height", "spatial_shard_width"}, ( assert self.vae_parallel_mode in {"tile", "auto", "spatial_shard_height", "spatial_shard_width"}, (
"vae_parallel_mode must be one of {'tile', 'spatial_shard_height', 'spatial_shard_width'}, " "vae_parallel_mode must be one of "
"{'tile', 'auto', 'spatial_shard_height', 'spatial_shard_width'}, "
f"but got {self.vae_parallel_mode!r}." f"but got {self.vae_parallel_mode!r}."
) )
if self.allgather_degree > 1: if self.allgather_degree > 1:
@@ -23,6 +23,30 @@ from vllm_omni.platforms import current_omni_platform
logger = init_logger(__name__) logger = init_logger(__name__)
def select_auto_spatial_shard_split_dim(
z_shape: tuple[int, ...],
world_size: int,
) -> str | None:
"""Select a profitable Wan spatial-shard axis for one latent shape.
This selector runs only after Diffusers has already chosen ``tiled_decode``
for a spatially large request. Splitting the longer eligible axis reduces
halo traffic for rectangular requests and improves per-rank balance.
"""
if len(z_shape) != 5 or world_size <= 1:
return None
_, _, _, height, width = z_shape
candidates = [
(extent, split_dim) for extent, split_dim in ((height, "height"), (width, "width")) if extent >= world_size
]
if not candidates:
return None
# Prefer width for square latents: it was modestly faster in the existing
# four-GPU Wan benchmark and keeps the choice deterministic.
return max(candidates, key=lambda candidate: (candidate[0], candidate[1] == "width"))[1]
class OmniAutoencoderKLWan(AutoencoderKLWan): class OmniAutoencoderKLWan(AutoencoderKLWan):
def _execution_context(self): def _execution_context(self):
try: try:
@@ -56,6 +80,19 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin):
model.init_distributed() model.init_distributed()
return model return model
def set_parallel_size(self, parallel_size: int, mode: str = "tile") -> None:
super().set_parallel_size(parallel_size, mode=mode)
if mode not in {"auto", "spatial_shard_height", "spatial_shard_width"}:
return
executor = self.distributed_executor
world_size = dist.get_world_size(group=executor.group)
if parallel_size <= 1 or parallel_size != world_size:
return
split_dim = "width" if mode == "spatial_shard_width" else "height"
wan_spatial_shard.install_wan_spatial_shard_decode(self, executor.group, split_dim=split_dim)
def tile_split(self, z: torch.Tensor) -> tuple[list[TileTask], GridSpec]: def tile_split(self, z: torch.Tensor) -> tuple[list[TileTask], GridSpec]:
_, _, num_frames, height, width = z.shape _, _, num_frames, height, width = z.shape
sample_height = height * self.spatial_compression_ratio sample_height = height * self.spatial_compression_ratio
@@ -270,20 +307,26 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin):
dec = torch.clamp(dec, min=-1.0, max=1.0) dec = torch.clamp(dec, min=-1.0, max=1.0)
return dec return dec
def _spatial_shard_decode_split_dim(self) -> str | None: def _spatial_shard_decode_split_dim(
self,
z: torch.Tensor | None = None,
world_size: int | None = None,
) -> str | None:
mode = self.distributed_executor.parallel_mode mode = self.distributed_executor.parallel_mode
if mode == "spatial_shard_width": if mode == "spatial_shard_width":
return "width" return "width"
if mode == "spatial_shard_height": if mode == "spatial_shard_height":
return "height" return "height"
if mode == "auto" and z is not None and world_size is not None:
return select_auto_spatial_shard_split_dim(tuple(z.shape), world_size)
return None return None
def _spatial_shard_decode_enabled(self, z: torch.Tensor) -> bool: def _spatial_shard_decode_enabled(self, z: torch.Tensor) -> bool:
split_dim = self._spatial_shard_decode_split_dim() if self.distributed_executor.parallel_mode == "tile":
if split_dim is None:
return False return False
if z.ndim != 5: if z.ndim != 5:
logger.warning("Wan VAE spatial sharded decode expects 5D latent input; falling back to tiled decode.") if self.distributed_executor.parallel_mode != "tile":
logger.warning("Wan VAE spatial sharded decode expects 5D latent input; falling back to tiled decode.")
return False return False
if not self.is_distributed_enabled(): if not self.is_distributed_enabled():
return False return False
@@ -292,23 +335,32 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin):
world_size = dist.get_world_size(group=group) world_size = dist.get_world_size(group=group)
requested_size = int(self.distributed_executor.parallel_size) requested_size = int(self.distributed_executor.parallel_size)
if requested_size != world_size: if requested_size != world_size:
logger.warning( if self.distributed_executor.parallel_mode == "auto":
"Wan VAE spatial sharded decode currently requires vae_patch_parallel_size " logger.debug(
"to match the DIT group size; falling back to tiled decode. " "Wan VAE auto decode selected tile mode for a partial DiT group (requested=%s dit_group=%s)",
"requested=%s dit_group=%s split_dim=%s", requested_size,
requested_size, world_size,
world_size, )
split_dim, elif self._spatial_shard_decode_split_dim() is not None:
) logger.warning(
"Wan VAE spatial sharded decode currently requires vae_patch_parallel_size "
"to match the DIT group size; falling back to tiled decode. "
"requested=%s dit_group=%s split_dim=%s",
requested_size,
world_size,
self._spatial_shard_decode_split_dim(),
)
return False return False
return True return self._spatial_shard_decode_split_dim(z, world_size) is not None
def tiled_decode(self, z: torch.Tensor, return_dict: bool = True): def tiled_decode(self, z: torch.Tensor, return_dict: bool = True):
if not self.is_distributed_enabled(): if not self.is_distributed_enabled():
return super().tiled_decode(z, return_dict=return_dict) return super().tiled_decode(z, return_dict=return_dict)
if self._spatial_shard_decode_enabled(z): if self._spatial_shard_decode_enabled(z):
split_dim = self._spatial_shard_decode_split_dim() world_size = dist.get_world_size(group=self.distributed_executor.group)
split_dim = self._spatial_shard_decode_split_dim(z, world_size)
assert split_dim is not None
logger.debug("Decode running with Wan VAE spatial_shard_%s mode", split_dim) logger.debug("Decode running with Wan VAE spatial_shard_%s mode", split_dim)
return wan_spatial_shard.spatial_shard_decode( return wan_spatial_shard.spatial_shard_decode(
self, self,
@@ -52,6 +52,11 @@ _SPATIAL_SHARD_CONTEXT: ContextVar[SpatialShardContext | None] = ContextVar(
) )
def _active_spatial_shard_split_dim() -> str | None:
context = _SPATIAL_SHARD_CONTEXT.get()
return context.split_dim if context is not None else None
def _spatial_dim(split_dim: str) -> int: def _spatial_dim(split_dim: str) -> int:
if split_dim == "height": if split_dim == "height":
return -2 return -2
@@ -332,21 +337,27 @@ class WanDistZeroPad2d(nn.Module):
padding: tuple[int, int, int, int], padding: tuple[int, int, int, int],
group: dist.ProcessGroup, group: dist.ProcessGroup,
*, *,
split_dim: str = "height", defer_split_after_padding: bool = False,
split_padding: tuple[int, int] | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.padding = padding self.padding = padding
self.split_dim = split_dim self.defer_split_after_padding = defer_split_after_padding
default_split_padding = (padding[2], padding[3]) if split_dim == "height" else (padding[0], padding[1])
self.split_padding = split_padding or default_split_padding
self.group = group self.group = group
def forward(self, x: torch.Tensor) -> torch.Tensor: def forward(self, x: torch.Tensor) -> torch.Tensor:
split_dim = _active_spatial_shard_split_dim()
if split_dim is None:
return F.pad(x, self.padding)
rank, world_size = _rank_world(self.group) rank, world_size = _rank_world(self.group)
left, right, top, bottom = self.padding left, right, top, bottom = self.padding
if self.defer_split_after_padding:
if split_dim == "height":
bottom = 0
else:
right = 0
if world_size > 1: if world_size > 1:
if self.split_dim == "height": if split_dim == "height":
top = top if rank == 0 else 0 top = top if rank == 0 else 0
bottom = bottom if rank == world_size - 1 else 0 bottom = bottom if rank == world_size - 1 else 0
else: else:
@@ -360,8 +371,7 @@ class WanDistConv2d(nn.Conv2d):
self, self,
source: nn.Conv2d, source: nn.Conv2d,
group: dist.ProcessGroup, group: dist.ProcessGroup,
split_dim: str = "height", deferred_padding: tuple[int, int, int, int] | None = None,
split_padding: tuple[int, int] | None = None,
): ):
super().__init__( super().__init__(
source.in_channels, source.in_channels,
@@ -378,75 +388,117 @@ class WanDistConv2d(nn.Conv2d):
) )
self.load_state_dict(source.state_dict()) self.load_state_dict(source.state_dict())
self.group = group self.group = group
self.split_dim = split_dim self._direct_padding = source.padding
self.split_tensor_dim = _spatial_dim(split_dim) self._direct_padding_mode = source.padding_mode
kernel_extent = self.kernel_size[-2] if split_dim == "height" else self.kernel_size[-1] self._direct_reversed_padding = source._reversed_padding_repeated_twice
self.halo_size = (kernel_extent - 1) // 2 self._deferred_padding = deferred_padding
pad_h = source.padding[-2] if isinstance(source.padding, tuple) else source.padding source_padding = source.padding if isinstance(source.padding, tuple) else (source.padding, source.padding)
pad_w = source.padding[-1] if isinstance(source.padding, tuple) else source.padding if len(source_padding) != 2 or not all(isinstance(value, int) for value in source_padding):
if split_dim == "height": raise ValueError(f"Wan spatial-shard Conv2d requires integer padding, got {source.padding!r}.")
if split_padding is None: self._spatial_pad_h = int(source_padding[-2])
split_padding = (pad_h, pad_h) self._spatial_pad_w = int(source_padding[-1])
self._non_split_padding = (pad_w, pad_w, 0, 0) self.register_buffer("_halo_recv_top_buf", None, persistent=False)
self.kernel_extent = self.kernel_size[-2] self.register_buffer("_halo_recv_bottom_buf", None, persistent=False)
self.stride_extent = self.stride[-2] self._trim_cache: dict[tuple[str, int], tuple[int, int, int]] = {}
else:
if split_padding is None:
split_padding = (pad_w, pad_w)
self._non_split_padding = (0, 0, pad_h, pad_h)
self.kernel_extent = self.kernel_size[-1]
self.stride_extent = self.stride[-1]
self.split_pad_left, self.split_pad_right = split_padding
self._halo_recv_top_buf: torch.Tensor | None = None
self._halo_recv_bottom_buf: torch.Tensor | None = None
self._trim_cache: dict[int, tuple[int, int, int]] = {}
def forward(self, x: torch.Tensor) -> torch.Tensor: def forward(self, x: torch.Tensor) -> torch.Tensor:
x = F.pad(x, self._non_split_padding) split_dim = _active_spatial_shard_split_dim()
if split_dim is None:
if self._direct_padding_mode != "zeros":
x = F.pad(x, self._direct_reversed_padding, mode=self._direct_padding_mode)
padding: str | tuple[int, int] = (0, 0)
else:
padding = self._direct_padding
return F.conv2d(x, self.weight, self.bias, self.stride, padding, self.dilation, self.groups)
split_tensor_dim = _spatial_dim(split_dim)
if split_dim == "height":
kernel_extent = self.kernel_size[-2]
stride_extent = self.stride[-2]
non_split_padding = (self._spatial_pad_w, self._spatial_pad_w, 0, 0)
default_split_padding = (self._spatial_pad_h, self._spatial_pad_h)
deferred_split_padding = (
(self._deferred_padding[2], self._deferred_padding[3]) if self._deferred_padding is not None else None
)
else:
kernel_extent = self.kernel_size[-1]
stride_extent = self.stride[-1]
non_split_padding = (0, 0, self._spatial_pad_h, self._spatial_pad_h)
default_split_padding = (self._spatial_pad_w, self._spatial_pad_w)
deferred_split_padding = (
(self._deferred_padding[0], self._deferred_padding[1]) if self._deferred_padding is not None else None
)
split_pad_left, split_pad_right = deferred_split_padding or default_split_padding
halo_size = (kernel_extent - 1) // 2
x = F.pad(x, non_split_padding)
x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange( x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange(
x, x,
group=self.group, group=self.group,
halo_size=self.halo_size, halo_size=halo_size,
split_dim=self.split_dim, split_dim=split_dim,
recv_top_buf=self._halo_recv_top_buf, recv_top_buf=self._halo_recv_top_buf,
recv_bottom_buf=self._halo_recv_bottom_buf, recv_bottom_buf=self._halo_recv_bottom_buf,
) )
shift, start, upper_bound = self._get_trim_params(x.shape[self.split_tensor_dim]) shift, start, upper_bound = self._get_trim_params(
x.shape[split_tensor_dim],
split_dim=split_dim,
halo_size=halo_size,
split_pad_left=split_pad_left,
split_pad_right=split_pad_right,
kernel_extent=kernel_extent,
stride_extent=stride_extent,
)
if shift: if shift:
x_padded = _narrow_along_dim( x_padded = _narrow_along_dim(
x_padded, x_padded,
self.split_tensor_dim, split_tensor_dim,
shift, shift,
x_padded.shape[self.split_tensor_dim] - shift, x_padded.shape[split_tensor_dim] - shift,
) )
out = super().forward(x_padded) out = super().forward(x_padded)
out = _trim_local_conv_output(out, self.halo_size, start, upper_bound, split_dim=self.split_dim) out = _trim_local_conv_output(out, halo_size, start, upper_bound, split_dim=split_dim)
return _zero_invalid_extent(out, split_dim=self.split_dim) return _zero_invalid_extent(out, split_dim=split_dim)
def _get_trim_params(self, local_extent: int) -> tuple[int, int, int]: def _get_trim_params(
trim_params = self._trim_cache.get(local_extent) self,
local_extent: int,
*,
split_dim: str,
halo_size: int,
split_pad_left: int,
split_pad_right: int,
kernel_extent: int,
stride_extent: int,
) -> tuple[int, int, int]:
cache_key = (split_dim, local_extent)
trim_params = self._trim_cache.get(cache_key)
if trim_params is None: if trim_params is None:
rank, world_size = _rank_world(self.group) rank, world_size = _rank_world(self.group)
trim_params = _compute_conv_trim_params( trim_params = _compute_conv_trim_params(
local_extent=local_extent, local_extent=local_extent,
rank=rank, rank=rank,
world_size=world_size, world_size=world_size,
halo_size=self.halo_size, halo_size=halo_size,
pad_before=self.split_pad_left, pad_before=split_pad_left,
pad_after=self.split_pad_right, pad_after=split_pad_right,
kernel_extent=self.kernel_extent, kernel_extent=kernel_extent,
stride_extent=self.stride_extent, stride_extent=stride_extent,
) )
self._trim_cache[local_extent] = trim_params self._trim_cache[cache_key] = trim_params
return trim_params return trim_params
class WanDistCausalConv3d(nn.Conv3d): class WanDistCausalConv3d(nn.Conv3d):
# Cross-PR compatibility contract for the lossless Wan data-movement
# installer: it may fuse the no-context direct path, but must leave this
# wrapper in control whenever a spatial-shard context is active.
_vllm_omni_dynamic_spatial_shard_conv = True
def __init__( def __init__(
self, self,
source: nn.Conv3d, source: nn.Conv3d,
group: dist.ProcessGroup, group: dist.ProcessGroup,
split_dim: str = "height",
): ):
super().__init__( super().__init__(
source.in_channels, source.in_channels,
@@ -463,88 +515,98 @@ class WanDistCausalConv3d(nn.Conv3d):
) )
self.load_state_dict(source.state_dict()) self.load_state_dict(source.state_dict())
self.group = group self.group = group
self.split_dim = split_dim
self.split_tensor_dim = _spatial_dim(split_dim)
source_padding = getattr(source, "_padding", None) source_padding = getattr(source, "_padding", None)
if source_padding is None: if source_padding is None:
p_t, p_h, p_w = source.padding p_t, p_h, p_w = source.padding
source_padding = (p_w, p_w, p_h, p_h, 2 * p_t, 0) source_padding = (p_w, p_w, p_h, p_h, 2 * p_t, 0)
self._source_padding = tuple(source_padding) self._source_padding = tuple(source_padding)
if split_dim == "height": self.register_buffer("_halo_recv_top_buf", None, persistent=False)
self.split_pad_left = int(self._source_padding[2]) self.register_buffer("_halo_recv_bottom_buf", None, persistent=False)
self.split_pad_right = int(self._source_padding[3]) self._trim_cache: dict[tuple[str, int], tuple[int, int, int]] = {}
self.kernel_extent = self.kernel_size[-2]
self.stride_extent = self.stride[-2]
padding = (
self._source_padding[0],
self._source_padding[1],
0,
0,
self._source_padding[4],
self._source_padding[5],
)
else:
self.split_pad_left = int(self._source_padding[0])
self.split_pad_right = int(self._source_padding[1])
self.kernel_extent = self.kernel_size[-1]
self.stride_extent = self.stride[-1]
padding = (
0,
0,
self._source_padding[2],
self._source_padding[3],
self._source_padding[4],
self._source_padding[5],
)
self.halo_size = (self.kernel_extent - 1) // 2
self._padding = padding if self.halo_size > 0 else self._source_padding
self._halo_recv_top_buf: torch.Tensor | None = None
self._halo_recv_bottom_buf: torch.Tensor | None = None
self._trim_cache: dict[int, tuple[int, int, int]] = {}
def forward(self, x: torch.Tensor, cache_x: torch.Tensor | None = None) -> torch.Tensor: def forward(self, x: torch.Tensor, cache_x: torch.Tensor | None = None) -> torch.Tensor:
padding = list(self._padding) split_dim = _active_spatial_shard_split_dim()
padding = list(self._source_padding)
if cache_x is not None and padding[4] > 0: if cache_x is not None and padding[4] > 0:
cache_x = cache_x.to(x.device) cache_x = cache_x.to(x.device)
x = torch.cat([cache_x, x], dim=2) x = torch.cat([cache_x, x], dim=2)
padding[4] -= cache_x.shape[2] padding[4] -= cache_x.shape[2]
if split_dim is None:
return super().forward(F.pad(x, padding))
split_tensor_dim = _spatial_dim(split_dim)
if split_dim == "height":
split_pad_left = int(padding[2])
split_pad_right = int(padding[3])
kernel_extent = self.kernel_size[-2]
stride_extent = self.stride[-2]
padding[2] = 0
padding[3] = 0
else:
split_pad_left = int(padding[0])
split_pad_right = int(padding[1])
kernel_extent = self.kernel_size[-1]
stride_extent = self.stride[-1]
padding[0] = 0
padding[1] = 0
halo_size = (kernel_extent - 1) // 2
x = F.pad(x, padding) x = F.pad(x, padding)
x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange( x_padded, self._halo_recv_top_buf, self._halo_recv_bottom_buf = halo_exchange(
x, x,
group=self.group, group=self.group,
halo_size=self.halo_size, halo_size=halo_size,
split_dim=self.split_dim, split_dim=split_dim,
recv_top_buf=self._halo_recv_top_buf, recv_top_buf=self._halo_recv_top_buf,
recv_bottom_buf=self._halo_recv_bottom_buf, recv_bottom_buf=self._halo_recv_bottom_buf,
) )
shift, start, upper_bound = self._get_trim_params(x.shape[self.split_tensor_dim]) shift, start, upper_bound = self._get_trim_params(
x.shape[split_tensor_dim],
split_dim=split_dim,
halo_size=halo_size,
split_pad_left=split_pad_left,
split_pad_right=split_pad_right,
kernel_extent=kernel_extent,
stride_extent=stride_extent,
)
if shift: if shift:
x_padded = _narrow_along_dim( x_padded = _narrow_along_dim(
x_padded, x_padded,
self.split_tensor_dim, split_tensor_dim,
shift, shift,
x_padded.shape[self.split_tensor_dim] - shift, x_padded.shape[split_tensor_dim] - shift,
) )
out = super().forward(x_padded) out = super().forward(x_padded)
out = _trim_local_conv_output(out, self.halo_size, start, upper_bound, split_dim=self.split_dim) out = _trim_local_conv_output(out, halo_size, start, upper_bound, split_dim=split_dim)
return _zero_invalid_extent(out, split_dim=self.split_dim) return _zero_invalid_extent(out, split_dim=split_dim)
def _get_trim_params(self, local_extent: int) -> tuple[int, int, int]: def _get_trim_params(
trim_params = self._trim_cache.get(local_extent) self,
local_extent: int,
*,
split_dim: str,
halo_size: int,
split_pad_left: int,
split_pad_right: int,
kernel_extent: int,
stride_extent: int,
) -> tuple[int, int, int]:
cache_key = (split_dim, local_extent)
trim_params = self._trim_cache.get(cache_key)
if trim_params is None: if trim_params is None:
rank, world_size = _rank_world(self.group) rank, world_size = _rank_world(self.group)
trim_params = _compute_conv_trim_params( trim_params = _compute_conv_trim_params(
local_extent=local_extent, local_extent=local_extent,
rank=rank, rank=rank,
world_size=world_size, world_size=world_size,
halo_size=self.halo_size, halo_size=halo_size,
pad_before=self.split_pad_left, pad_before=split_pad_left,
pad_after=self.split_pad_right, pad_after=split_pad_right,
kernel_extent=self.kernel_extent, kernel_extent=kernel_extent,
stride_extent=self.stride_extent, stride_extent=stride_extent,
) )
self._trim_cache[local_extent] = trim_params self._trim_cache[cache_key] = trim_params
return trim_params return trim_params
@@ -591,12 +653,15 @@ def _trim_local_conv_output(
return out return out
def _patch_attention_block(module: nn.Module, group: dist.ProcessGroup, split_dim: str) -> None: def _patch_attention_block(module: nn.Module, group: dist.ProcessGroup) -> None:
if getattr(module, "_vllm_omni_spatial_shard_attention", False): if getattr(module, "_vllm_omni_spatial_shard_attention", False):
return return
orig_forward = module.forward orig_forward = module.forward
def _forward(self: nn.Module, x: torch.Tensor, *args: Any, **kwargs: Any) -> torch.Tensor: def _forward(self: nn.Module, x: torch.Tensor, *args: Any, **kwargs: Any) -> torch.Tensor:
split_dim = _active_spatial_shard_split_dim()
if split_dim is None:
return orig_forward(x, *args, **kwargs)
_, world_size = _rank_world(group) _, world_size = _rank_world(group)
if world_size <= 1: if world_size <= 1:
return orig_forward(x, *args, **kwargs) return orig_forward(x, *args, **kwargs)
@@ -630,7 +695,6 @@ def _replace_child(
name: str, name: str,
child: nn.Module, child: nn.Module,
group: dist.ProcessGroup, group: dist.ProcessGroup,
split_dim: str,
) -> None: ) -> None:
if child.__class__.__name__ == "WanCausalConv3d": if child.__class__.__name__ == "WanCausalConv3d":
setattr( setattr(
@@ -639,51 +703,39 @@ def _replace_child(
WanDistCausalConv3d( WanDistCausalConv3d(
child, child,
group, group,
split_dim=split_dim,
), ),
) )
return return
if isinstance(child, nn.ZeroPad2d): if isinstance(child, nn.ZeroPad2d):
padding = tuple(int(p) for p in child.padding) padding = tuple(int(p) for p in child.padding)
module_padding = padding
if parent.__class__.__name__ == "Sequential":
# Let the following WanDistConv2d account for global after-edge
# padding; this module handles the non-split dimension and before edge.
if split_dim == "height":
module_padding = (padding[0], padding[1], padding[2], 0)
else:
module_padding = (padding[0], 0, padding[2], padding[3])
setattr( setattr(
parent, parent,
name, name,
WanDistZeroPad2d( WanDistZeroPad2d(
module_padding, padding,
group, group,
split_dim=split_dim, defer_split_after_padding=parent.__class__.__name__ == "Sequential",
split_padding=(padding[2], padding[3]) if split_dim == "height" else (padding[0], padding[1]),
), ),
) )
return return
if isinstance(child, nn.Conv2d): if isinstance(child, nn.Conv2d):
split_padding = None deferred_padding = None
if name == "1" and parent.__class__.__name__ == "Sequential": if name == "1" and parent.__class__.__name__ == "Sequential":
# WanResample downsample uses ZeroPad2d((0, 1, 0, 1)) before a # WanResample downsample uses ZeroPad2d((0, 1, 0, 1)) before a
# stride-2 conv with padding=0. Only the last rank should see the # stride-2 conv with padding=0. Only the last rank should see the
# bottom/right global padding, which is approximated by split_padding. # bottom/right global padding, which is approximated by split_padding.
prev = getattr(parent, "0", None) prev = getattr(parent, "0", None)
if isinstance(prev, WanDistZeroPad2d): if isinstance(prev, WanDistZeroPad2d):
split_padding = prev.split_padding deferred_padding = prev.padding
elif isinstance(prev, nn.ZeroPad2d): elif isinstance(prev, nn.ZeroPad2d):
pad = prev.padding deferred_padding = tuple(int(value) for value in prev.padding)
split_padding = (int(pad[2]), int(pad[3])) if split_dim == "height" else (int(pad[0]), int(pad[1]))
setattr( setattr(
parent, parent,
name, name,
WanDistConv2d( WanDistConv2d(
child, child,
group, group,
split_dim=split_dim, deferred_padding=deferred_padding,
split_padding=split_padding,
), ),
) )
@@ -691,21 +743,20 @@ def _replace_child(
def _patch_decoder_modules( def _patch_decoder_modules(
module: nn.Module, module: nn.Module,
group: dist.ProcessGroup, group: dist.ProcessGroup,
split_dim: str,
inside_attention: bool = False, inside_attention: bool = False,
) -> None: ) -> None:
if module.__class__.__name__ == "WanAttentionBlock": if module.__class__.__name__ == "WanAttentionBlock":
_patch_attention_block(module, group, split_dim) _patch_attention_block(module, group)
inside_attention = True inside_attention = True
for name, child in list(module.named_children()): for name, child in list(module.named_children()):
if child.__class__.__name__ == "WanCausalConv3d": if child.__class__.__name__ == "WanCausalConv3d":
_replace_child(module, name, child, group, split_dim) _replace_child(module, name, child, group)
continue continue
if isinstance(child, nn.Conv2d) and not inside_attention: if isinstance(child, nn.Conv2d) and not inside_attention:
_replace_child(module, name, child, group, split_dim) _replace_child(module, name, child, group)
continue continue
_patch_decoder_modules(child, group, split_dim, inside_attention=inside_attention) _patch_decoder_modules(child, group, inside_attention=inside_attention)
def _decoder_upsample_count(decoder: nn.Module) -> int: def _decoder_upsample_count(decoder: nn.Module) -> int:
@@ -716,16 +767,25 @@ def _decoder_upsample_count(decoder: nn.Module) -> int:
return count return count
def _clear_wan_spatial_shard_runtime_buffers(vae: Any) -> None:
decoder = getattr(vae, "decoder", None)
if decoder is None:
return
for module in decoder.modules():
if "_halo_recv_top_buf" in module._buffers:
module._halo_recv_top_buf = None
if "_halo_recv_bottom_buf" in module._buffers:
module._halo_recv_bottom_buf = None
def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_dim: str = "height") -> None: def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_dim: str = "height") -> None:
"""Patch ``vae.decoder`` once for spatially-sharded decode. """Patch ``vae.decoder`` once for spatially-sharded decode.
This mutates the already-loaded decoder in place by swapping its spatial This mutates the already-loaded decoder in place by swapping its spatial
convolutions/padding for halo-exchanging variants and wrapping convolutions/padding for context-aware variants and attaching a dedicated
``decoder.forward``. The patch is permanent for the lifetime of the VAE spatial forward. Outside that forward the replacements execute their exact
instance and is applied only once (subsequent calls are no-ops). A given local PyTorch behavior, so a later auto-selected tile request remains safe.
instance is bound to a single ``split_dim``; switching between The same installed decoder can alternate height and width sharding.
``"height"`` and ``"width"`` requires a fresh VAE instance and raises here
otherwise.
Only group-relative rank 0 assembles the final decoded frame, mirroring the Only group-relative rank 0 assembles the final decoded frame, mirroring the
distributed tiled-decode ``broadcast_result=False`` contract; the other ranks distributed tiled-decode ``broadcast_result=False`` contract; the other ranks
@@ -733,18 +793,12 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d
""" """
_spatial_dim(split_dim) _spatial_dim(split_dim)
if getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False): if getattr(vae, "_vllm_omni_wan_spatial_shard_installed", False):
installed_split_dim = getattr(vae, "_vllm_omni_wan_spatial_shard_split_dim", "height")
if installed_split_dim != split_dim:
raise ValueError(
"Wan spatial-shard VAE decoder was already patched for "
f"{installed_split_dim!r} split; create a fresh VAE instance to use {split_dim!r} split."
)
return return
decoder = getattr(vae, "decoder", None) decoder = getattr(vae, "decoder", None)
if decoder is None: if decoder is None:
raise ValueError("Wan spatial-shard VAE decode requires a decoder module.") raise ValueError("Wan spatial-shard VAE decode requires a decoder module.")
_patch_decoder_modules(decoder, group, split_dim) _patch_decoder_modules(decoder, group)
upsample_count = _decoder_upsample_count(decoder) upsample_count = _decoder_upsample_count(decoder)
orig_forward = decoder.forward orig_forward = decoder.forward
@@ -754,6 +808,8 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d
feat_cache: list[torch.Tensor] | None = None, feat_cache: list[torch.Tensor] | None = None,
feat_idx: list[int] | None = None, feat_idx: list[int] | None = None,
first_chunk: bool = False, first_chunk: bool = False,
*,
split_dim: str,
) -> torch.Tensor: ) -> torch.Tensor:
if feat_idx is None: if feat_idx is None:
feat_idx = [0] feat_idx = [0]
@@ -781,10 +837,9 @@ def install_wan_spatial_shard_decode(vae: Any, group: dist.ProcessGroup, split_d
_SPATIAL_SHARD_CONTEXT.reset(token) _SPATIAL_SHARD_CONTEXT.reset(token)
return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=0) return gather_and_trim_extent(out, expected_extent=expected_extent, split_dim=split_dim, group=group, dst=0)
decoder.forward = MethodType(_forward, decoder) decoder._vllm_omni_spatial_shard_forward = MethodType(_forward, decoder)
vae._vllm_omni_wan_spatial_shard_installed = True vae._vllm_omni_wan_spatial_shard_installed = True
vae._vllm_omni_wan_spatial_shard_split_dim = split_dim logger.info("Installed Wan VAE dynamic-axis spatial-shard decode.")
logger.info("Installed Wan VAE %s-sharded decode.", split_dim)
def spatial_shard_decode( def spatial_shard_decode(
@@ -813,11 +868,12 @@ def spatial_shard_decode(
decoded_chunks = [] decoded_chunks = []
for i in range(z.shape[2]): for i in range(z.shape[2]):
vae._conv_idx = [0] vae._conv_idx = [0]
chunk = vae.decoder( chunk = vae.decoder._vllm_omni_spatial_shard_forward(
x[:, :, i : i + 1, :, :], x[:, :, i : i + 1, :, :],
feat_cache=vae._feat_map, feat_cache=vae._feat_map,
feat_idx=vae._conv_idx, feat_idx=vae._conv_idx,
first_chunk=(i == 0), first_chunk=(i == 0),
split_dim=split_dim,
) )
if produce_output: if produce_output:
decoded_chunks.append(chunk) decoded_chunks.append(chunk)
@@ -831,6 +887,10 @@ def spatial_shard_decode(
out = z.new_zeros(0) out = z.new_zeros(0)
finally: finally:
vae.clear_cache() vae.clear_cache()
# Halo buffers are request-shape scratch space allocated outside the
# tagged weight pool. Drop live references so sleep/offload never has
# to preserve or discard them as model state.
_clear_wan_spatial_shard_runtime_buffers(vae)
if not return_dict: if not return_dict:
return (out,) return (out,)
@@ -1,299 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Q/K RMSNorm followed by packed non-interleaved RoPE.
The public contract is shared by diffusion attention implementations:
* ``q`` and ``k`` are ``[tokens, heads, head_dim]``;
* norm weights are one-dimensional ``[head_dim]`` tensors;
* ``rope_table`` is ``[tokens, rotary_dim]`` and stores
``[cos(theta), sin(theta)]`` with ``theta`` of width ``rotary_dim // 2``.
The CUDA fast path fuses RMSNorm and RoPE without materializing normalized Q/K
or rotary-product intermediates. Unsupported inputs use the eager reference.
"""
from __future__ import annotations
import torch
import torch.nn.functional as F
from torch.library import Library
from vllm.platforms import current_platform
from vllm.triton_utils import HAS_TRITON, tl, triton
from vllm.utils.torch_utils import direct_register_custom_op
_FUSED_HEAD_DIM = 128
_FUSED_ROTARY_DIM = 96
_HEADS_PER_PROGRAM = 8
def _apply_rope_table(
x: torch.Tensor,
rope_table: torch.Tensor,
rotary_dim: int,
) -> torch.Tensor:
half = rotary_dim // 2
cos = rope_table[..., :half].to(x.dtype).unsqueeze(1)
sin = rope_table[..., half:].to(x.dtype).unsqueeze(1)
first = x[..., :half]
second = x[..., half:rotary_dim]
return torch.cat(
(
first * cos - second * sin,
second * cos + first * sin,
x[..., rotary_dim:],
),
dim=-1,
)
if HAS_TRITON:
@triton.jit
def _rms_norm_rope_kernel(
x_ptr,
weight_ptr,
rope_table_ptr,
out_ptr,
x_stride_t,
x_stride_h,
x_stride_d,
rope_stride_t,
out_stride_t,
out_stride_h,
out_stride_d,
num_heads: tl.constexpr,
head_dim: tl.constexpr,
rotary_half: tl.constexpr,
eps: tl.constexpr,
heads_per_program: tl.constexpr,
):
token = tl.program_id(0)
head_group = tl.program_id(1)
heads = head_group * heads_per_program + tl.arange(0, heads_per_program)
dims = tl.arange(0, head_dim)
mask = heads[:, None] < num_heads
offsets = token * x_stride_t + heads[:, None] * x_stride_h + dims[None, :] * x_stride_d
x = tl.load(x_ptr + offsets, mask=mask, other=0.0).to(tl.float32)
weight = tl.load(weight_ptr + dims).to(tl.float32)
inv_rms = tl.rsqrt(tl.sum(x * x, axis=1) / head_dim + eps)
normalized = (x * inv_rms[:, None] * weight[None, :]).to(tl.bfloat16)
rotary_dim = rotary_half * 2
pair_dims = tl.where(
dims < rotary_half,
dims + rotary_half,
tl.where(dims < rotary_dim, dims - rotary_half, dims),
)
pair_offsets = token * x_stride_t + heads[:, None] * x_stride_h + pair_dims[None, :] * x_stride_d
pair_x = tl.load(x_ptr + pair_offsets, mask=mask, other=0.0).to(tl.float32)
pair_weight = tl.load(weight_ptr + pair_dims).to(tl.float32)
pair_normalized = (pair_x * inv_rms[:, None] * pair_weight[None, :]).to(tl.bfloat16)
freq_dims = tl.where(
dims < rotary_half,
dims,
tl.where(dims < rotary_dim, dims - rotary_half, 0),
)
table_offsets = token * rope_stride_t + freq_dims
cos = tl.load(rope_table_ptr + table_offsets).to(tl.float32)
sin = tl.load(rope_table_ptr + table_offsets + rotary_half).to(tl.float32)
first = normalized.to(tl.float32) * cos - pair_normalized.to(tl.float32) * sin
second = normalized.to(tl.float32) * cos + pair_normalized.to(tl.float32) * sin
output = tl.where(
dims < rotary_dim,
tl.where(dims < rotary_half, first, second),
normalized.to(tl.float32),
)
out_offsets = token * out_stride_t + heads[:, None] * out_stride_h + dims[None, :] * out_stride_d
tl.store(out_ptr + out_offsets, output, mask=mask)
def _eager_qk_norm_rope(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
head_dim: int,
rotary_dim: int,
) -> tuple[torch.Tensor, torch.Tensor]:
q_norm = F.rms_norm(q, (head_dim,), q_weight, eps)
k_norm = F.rms_norm(k, (head_dim,), k_weight, eps)
return (
_apply_rope_table(q_norm, rope_table, rotary_dim),
_apply_rope_table(k_norm, rope_table, rotary_dim),
)
def _fused_cuda_supported(
q: torch.Tensor,
k: torch.Tensor,
head_dim: int,
rotary_dim: int,
) -> bool:
return (
HAS_TRITON
and current_platform.is_cuda()
and q.is_cuda
and k.is_cuda
and q.dtype == torch.bfloat16
and k.dtype == torch.bfloat16
and head_dim == _FUSED_HEAD_DIM
and rotary_dim == _FUSED_ROTARY_DIM
)
def _launch_fused_rms_norm_rope(
x: torch.Tensor,
weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
) -> torch.Tensor:
tokens, heads, head_dim = x.shape
rotary_half = rope_table.shape[-1] // 2
out = torch.empty(x.shape, dtype=x.dtype, device=x.device)
if tokens == 0:
return out
grid = (tokens, triton.cdiv(heads, _HEADS_PER_PROGRAM))
_rms_norm_rope_kernel[grid](
x,
weight,
rope_table,
out,
x.stride(0),
x.stride(1),
x.stride(2),
rope_table.stride(0),
out.stride(0),
out.stride(1),
out.stride(2),
num_heads=heads,
head_dim=head_dim,
rotary_half=rotary_half,
eps=eps,
heads_per_program=_HEADS_PER_PROGRAM,
num_warps=8,
)
return out
def _fused_qk_norm_rope_impl(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
head_dim: int,
rotary_dim: int,
) -> tuple[torch.Tensor, torch.Tensor]:
if not _fused_cuda_supported(q, k, head_dim, rotary_dim):
return _eager_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
eps,
head_dim,
rotary_dim,
)
return (
_launch_fused_rms_norm_rope(q, q_weight, rope_table, eps),
_launch_fused_rms_norm_rope(k, k_weight, rope_table, eps),
)
def _fused_qk_norm_rope_fake(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
head_dim: int,
rotary_dim: int,
) -> tuple[torch.Tensor, torch.Tensor]:
del q_weight, k_weight, rope_table, eps, head_dim, rotary_dim
return torch.empty_like(q), torch.empty_like(k)
_OMNI_OP_LIB = Library("vllm_omni", "FRAGMENT")
if not hasattr(torch.ops.vllm_omni, "fused_qk_norm_rope"):
direct_register_custom_op(
op_name="fused_qk_norm_rope",
op_func=_fused_qk_norm_rope_impl,
fake_impl=_fused_qk_norm_rope_fake,
mutates_args=[],
target_lib=_OMNI_OP_LIB,
)
def fused_qk_norm_rope(
q: torch.Tensor,
k: torch.Tensor,
q_weight: torch.Tensor,
k_weight: torch.Tensor,
rope_table: torch.Tensor,
eps: float,
*,
head_dim: int | None = None,
rotary_dim: int | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Apply Q/K RMSNorm and packed non-interleaved RoPE."""
if q.ndim != 3 or k.ndim != 3:
raise ValueError(f"q and k must be [tokens, heads, head_dim], got {q.shape} and {k.shape}")
if q.shape[0] != k.shape[0] or q.shape[2] != k.shape[2]:
raise ValueError(f"q and k shapes are incompatible: {q.shape} and {k.shape}")
if q.dtype != k.dtype or q.device != k.device:
raise ValueError("q and k must have the same dtype and device")
if q.dtype not in (torch.bfloat16, torch.float16, torch.float32):
raise TypeError(f"Fused QK RMSNorm/RoPE requires floating inputs, got {q.dtype}")
head_dim = q.shape[-1] if head_dim is None else head_dim
rotary_dim = rope_table.shape[-1] if rotary_dim is None else rotary_dim
if q.shape[-1] != head_dim:
raise ValueError(f"Expected q/k head_dim={head_dim}, got {q.shape[-1]}")
if rotary_dim <= 0 or rotary_dim > head_dim or rotary_dim % 2:
raise ValueError(f"rotary_dim must be even and in [2, {head_dim}], got {rotary_dim}")
if q_weight.shape != (head_dim,) or k_weight.shape != (head_dim,):
raise ValueError(f"Expected norm weights [{head_dim}], got {tuple(q_weight.shape)} and {tuple(k_weight.shape)}")
if q_weight.device != q.device or k_weight.device != q.device:
raise ValueError("Q/K norm weights must be on the activation device")
if rope_table.device != q.device or rope_table.dtype != q.dtype:
raise ValueError("rope_table must have the same dtype and device as q/k")
if rope_table.shape != (q.shape[0], rotary_dim):
raise ValueError(f"Expected rope_table [{q.shape[0]}, {rotary_dim}], got {tuple(rope_table.shape)}")
q_weight = q_weight.contiguous()
k_weight = k_weight.contiguous()
rope_table = rope_table.contiguous()
if not _fused_cuda_supported(q, k, head_dim, rotary_dim):
return _fused_qk_norm_rope_impl(
q,
k,
q_weight,
k_weight,
rope_table,
eps,
head_dim,
rotary_dim,
)
return torch.ops.vllm_omni.fused_qk_norm_rope(
q,
k,
q_weight,
k_weight,
rope_table,
eps,
head_dim,
rotary_dim,
)
__all__ = ["fused_qk_norm_rope"]
+1 -5
View File
@@ -12,16 +12,12 @@ from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import (
LTX2Pipeline, LTX2Pipeline,
LTX2T2VDMD2Pipeline, LTX2T2VDMD2Pipeline,
) )
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import ( from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_two_stage import LTX2DistilledPipeline
LTX2DistilledPipeline,
LTX2TwoStagePipeline,
)
__all__ = [ __all__ = [
"LTX2Pipeline", "LTX2Pipeline",
"LTX2T2VDMD2Pipeline", "LTX2T2VDMD2Pipeline",
"LTX2I2VDMD2Pipeline", "LTX2I2VDMD2Pipeline",
"LTX2TwoStagePipeline",
"LTX2DistilledPipeline", "LTX2DistilledPipeline",
"get_ltx2_post_process_func", "get_ltx2_post_process_func",
"load_transformer_config", "load_transformer_config",
@@ -1,156 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Parse official LTX distilled adapters without materializing their tensors.
The official LTX adapters are raw safetensors files rather than PEFT
checkpoints. This module is deliberately LTX-specific: it translates the
official names into the locally constructed Diffusers transformer names and
describes the resulting adapter targets. Applying those targets is the job
of :mod:`ltx2_phase_adapter`.
"""
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import torch
import torch.nn as nn
from safetensors import safe_open
_IGNORED_OFFICIAL_LORA_MODULES = {"text_embedding_projection.aggregate_embed"}
_OFFICIAL_TO_DIFFUSERS_PREFIXES = (
("audio_prompt_adaln_single.", "audio_prompt_adaln."),
("prompt_adaln_single.", "prompt_adaln."),
("audio_adaln_single.", "audio_time_embed."),
("adaln_single.", "time_embed."),
("audio_patchify_proj", "audio_proj_in"),
("patchify_proj", "proj_in"),
("av_ca_video_scale_shift_adaln_single.", "av_cross_attn_video_scale_shift."),
("av_ca_audio_scale_shift_adaln_single.", "av_cross_attn_audio_scale_shift."),
("av_ca_a2v_gate_adaln_single.", "av_cross_attn_video_a2v_gate."),
("av_ca_v2a_gate_adaln_single.", "av_cross_attn_audio_v2a_gate."),
)
@dataclass(frozen=True)
class AdapterTarget:
"""One A/B pair mapped onto a local transformer linear layer."""
# Keep the original Diffusers module name for diagnostics after LTX packs
# Q/K/V into the runtime module path.
source_module: str
module: str
slice: str | None
a_key: str
b_key: str
a_shape: tuple[int, ...]
b_shape: tuple[int, ...]
@dataclass(frozen=True)
class AdapterManifest:
"""Tensor-free description of the fixed refinement adapter."""
path: str
targets: tuple[AdapterTarget, ...]
def _to_diffusers_module_name(name: str) -> str:
for official_prefix, diffusers_prefix in _OFFICIAL_TO_DIFFUSERS_PREFIXES:
if name.startswith(official_prefix):
return diffusers_prefix + name[len(official_prefix) :]
return name
def _resolve_lora_target(transformer: nn.Module, module_name: str) -> tuple[str, str | int | None] | None:
modules = dict(transformer.named_modules())
if module_name in modules:
return module_name, None
for packed_suffix, sub_suffix, shard_id in getattr(transformer, "stacked_params_mapping", ()):
if sub_suffix in module_name:
packed_name = module_name.replace(sub_suffix, packed_suffix)
if packed_name in modules:
return packed_name, shard_id
return None
def parse_ltx_adapter(transformer: nn.Module, path: str) -> AdapterManifest:
"""Translate one official raw LTX adapter into local Transformer targets."""
path = str(Path(path))
targets: list[AdapterTarget] = []
unresolved: list[str] = []
seen: set[tuple[str, str | int | None]] = set()
with safe_open(path, framework="pt", device="cpu") as handle:
tensor_names = set(handle.keys())
official_name_set = {
tensor_name.removeprefix("diffusion_model.").rsplit(".lora_", 1)[0]
for tensor_name in tensor_names
if tensor_name.endswith(".lora_A.weight")
}
official_b_names = {
tensor_name.removeprefix("diffusion_model.").rsplit(".lora_", 1)[0]
for tensor_name in tensor_names
if tensor_name.endswith(".lora_B.weight")
}
missing_a = sorted(official_b_names - official_name_set - _IGNORED_OFFICIAL_LORA_MODULES)
if missing_a:
raise ValueError(f"Missing paired LoRA A tensors for {missing_a} in {path}.")
official_names = sorted(official_name_set)
for official_name in official_names:
if official_name in _IGNORED_OFFICIAL_LORA_MODULES:
continue
key_prefix = f"diffusion_model.{official_name}"
key_a = f"{key_prefix}.lora_A.weight"
key_b = f"{key_prefix}.lora_B.weight"
if key_b not in tensor_names:
raise ValueError(f"Missing paired LoRA tensor {key_b!r} in {path}.")
a_shape = tuple(handle.get_slice(key_a).get_shape())
b_shape = tuple(handle.get_slice(key_b).get_shape())
if len(a_shape) != 2 or len(b_shape) != 2 or b_shape[1] != a_shape[0]:
raise ValueError(f"Invalid LoRA pair for {official_name}: A={a_shape}, B={b_shape}.")
source_module = _to_diffusers_module_name(official_name)
resolved = _resolve_lora_target(transformer, source_module)
if resolved is None:
unresolved.append(official_name)
continue
module, shard_id = resolved
target_id = (module, shard_id)
if target_id in seen:
raise ValueError(f"Official LTX LoRA contains duplicate target {module!r}, slice {shard_id!r}.")
seen.add(target_id)
targets.append(
AdapterTarget(
source_module=source_module,
module=module,
slice=shard_id,
a_key=key_a,
b_key=key_b,
a_shape=a_shape,
b_shape=b_shape,
)
)
if unresolved:
raise ValueError(f"Official LTX LoRA contains unmapped modules: {unresolved}.")
if not targets:
raise ValueError(f"No Transformer LoRA tensors were found in {path}.")
return AdapterManifest(path=path, targets=tuple(targets))
def iter_adapter_tensors(manifest: AdapterManifest) -> Iterator[tuple[AdapterTarget, torch.Tensor, torch.Tensor]]:
"""Stream adapter pairs in manifest order without a full checkpoint copy."""
with safe_open(manifest.path, framework="pt", device="cpu") as handle:
for target in manifest.targets:
yield (
target,
handle.get_tensor(target.a_key),
handle.get_tensor(target.b_key),
)
@@ -9,8 +9,7 @@ import inspect
import json import json
import logging import logging
import os import os
from dataclasses import dataclass, replace from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import torch import torch
@@ -20,8 +19,6 @@ from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel
from diffusers.pipelines.ltx2.vocoder import LTX2Vocoder from diffusers.pipelines.ltx2.vocoder import LTX2Vocoder
from diffusers.video_processor import VideoProcessor from diffusers.video_processor import VideoProcessor
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download
from safetensors import safe_open
from safetensors.torch import load_file
from transformers import AutoTokenizer, Gemma3ForConditionalGeneration from transformers import AutoTokenizer, Gemma3ForConditionalGeneration
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
@@ -35,7 +32,6 @@ from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery
if TYPE_CHECKING: if TYPE_CHECKING:
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
from .ltx2_request import LTXCheckpointKind, validate_ltx_checkpoint
from .ltx2_transformer import ( from .ltx2_transformer import (
LTX2VideoTransformer3DModel, LTX2VideoTransformer3DModel,
apply_interleaved_rotary_emb, apply_interleaved_rotary_emb,
@@ -74,9 +70,6 @@ class LTXComponentProfile:
video_vae_cls: type = AutoencoderKLLTX2Video video_vae_cls: type = AutoencoderKLLTX2Video
vocoder_cls: type = LTX2Vocoder vocoder_cls: type = LTX2Vocoder
vocoder_fallback_cls: type | None = None vocoder_fallback_cls: type | None = None
artifact_repo_id: str | None = None
latent_upsampler_filename: str | None = None
distilled_lora_filename: str | None = None
LTX2_COMPONENT_PROFILE = LTXComponentProfile( LTX2_COMPONENT_PROFILE = LTXComponentProfile(
@@ -108,96 +101,16 @@ LTX2_DISTILLED_COMPONENT_PROFILE = LTXComponentProfile(
video_vae_cls=DistributedAutoencoderKLLTX2Video, video_vae_cls=DistributedAutoencoderKLLTX2Video,
) )
LTX23_DISTILLED_COMPONENT_PROFILE = replace(
LTX23_COMPONENT_PROFILE,
name="ltx2_3_distilled",
resident_modules=(*LTX23_COMPONENT_PROFILE.resident_modules, "latent_upsampler"),
artifact_repo_id="Lightricks/LTX-2.3",
latent_upsampler_filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
)
LTX2_TWO_STAGE_COMPONENT_PROFILE = replace(
LTX2_COMPONENT_PROFILE,
name="ltx2_two_stage",
resident_modules=(*LTX2_COMPONENT_PROFILE.resident_modules, "latent_upsampler"),
artifact_repo_id="Lightricks/LTX-2",
latent_upsampler_filename="ltx-2-spatial-upscaler-x2-1.0.safetensors",
distilled_lora_filename="ltx-2-19b-distilled-lora-384.safetensors",
)
LTX23_TWO_STAGE_COMPONENT_PROFILE = replace(
LTX23_COMPONENT_PROFILE,
name="ltx2_3_two_stage",
resident_modules=(*LTX23_COMPONENT_PROFILE.resident_modules, "latent_upsampler"),
artifact_repo_id="Lightricks/LTX-2.3",
latent_upsampler_filename="ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
distilled_lora_filename="ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
)
_COMPONENT_PROFILES: dict[tuple[str, str], LTXComponentProfile] = { _COMPONENT_PROFILES: dict[tuple[str, str], LTXComponentProfile] = {
("one_stage", "2"): LTX2_COMPONENT_PROFILE, ("one_stage", "2"): LTX2_COMPONENT_PROFILE,
("one_stage", "2.3"): LTX23_COMPONENT_PROFILE, ("one_stage", "2.3"): LTX23_COMPONENT_PROFILE,
("two_stage", "2"): LTX2_TWO_STAGE_COMPONENT_PROFILE,
("two_stage", "2.3"): LTX23_TWO_STAGE_COMPONENT_PROFILE,
("distilled_two_stage", "2"): LTX2_DISTILLED_COMPONENT_PROFILE, ("distilled_two_stage", "2"): LTX2_DISTILLED_COMPONENT_PROFILE,
("distilled_two_stage", "2.3"): LTX23_DISTILLED_COMPONENT_PROFILE,
("dmd2", "2"): LTX2_COMPONENT_PROFILE, ("dmd2", "2"): LTX2_COMPONENT_PROFILE,
("dmd2", "2.3"): LTX23_COMPONENT_PROFILE, ("dmd2", "2.3"): LTX23_COMPONENT_PROFILE,
} }
def resolve_ltx_artifact(
model: str,
repo_id: str,
filename: str,
) -> str:
"""Resolve an official LTX sidecar from the model root or its Hub repository."""
candidate = Path(model) / filename
if candidate.is_file():
return str(candidate)
try:
return hf_hub_download(repo_id=repo_id, filename=filename)
except Exception as exc:
raise FileNotFoundError(
f"Unable to resolve LTX artifact {filename!r}. Searched {candidate}; "
f"place the file in the model root or make {repo_id} available."
) from exc
def _load_ltx_latent_upsampler_single_file(path: str, dtype: torch.dtype) -> LTX2LatentUpsamplerModel:
"""Load an official single-file upsampler into the Diffusers module."""
with safe_open(path, framework="pt", device="cpu") as handle:
metadata = handle.metadata() or {}
raw_config = json.loads(metadata.get("config", "{}"))
config = {
"in_channels": raw_config.get("in_channels", 128),
"mid_channels": raw_config.get("mid_channels", 1024),
"num_blocks_per_stage": raw_config.get("num_blocks_per_stage", 4),
"dims": raw_config.get("dims", 3),
"spatial_upsample": raw_config.get("spatial_upsample", True),
"temporal_upsample": raw_config.get("temporal_upsample", False),
"rational_spatial_scale": raw_config.get("spatial_scale", 2.0),
"use_rational_resampler": raw_config.get("rational_resampler", False),
}
with torch.device("cpu"):
upsampler = LTX2LatentUpsamplerModel(**config).to(dtype=dtype)
state_dict = load_file(path, device="cpu")
if "upsampler.0.weight" in state_dict and hasattr(upsampler.upsampler, "conv"):
state_dict["upsampler.conv.weight"] = state_dict.pop("upsampler.0.weight")
state_dict["upsampler.conv.bias"] = state_dict.pop("upsampler.0.bias")
missing, unexpected = upsampler.load_state_dict(state_dict, strict=False)
unresolved_missing = set(missing) - {"upsampler.blur_down.kernel"}
if unresolved_missing or unexpected:
raise ValueError(
f"Invalid LTX latent upsampler {path}: missing={sorted(unresolved_missing)}, "
f"unexpected={sorted(unexpected)}."
)
return upsampler
def resolve_ltx_component_profile(pipeline_kind: str, model_version: str) -> LTXComponentProfile: def resolve_ltx_component_profile(pipeline_kind: str, model_version: str) -> LTXComponentProfile:
"""Resolve component construction independently from execution recipes.""" """Resolve component construction independently from execution recipes."""
try: try:
@@ -479,34 +392,16 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
) )
if "latent_upsampler" in profile.resident_modules: if "latent_upsampler" in profile.resident_modules:
upsampler_config = os.path.join(model, "latent_upsampler", "config.json") # BlurDownsample constructs an integer kernel that must be initialized
if os.path.isfile(upsampler_config) or not local_files_only: # on CPU; component placement is handled uniformly after construction.
try: with torch.device("cpu"):
pipeline.latent_upsampler = _load_component( pipeline.latent_upsampler = _load_component(
LTX2LatentUpsamplerModel, LTX2LatentUpsamplerModel,
model,
"latent_upsampler",
local_files_only=local_files_only,
dtype=dtype,
)
except (OSError, ValueError):
if profile.latent_upsampler_filename is None or profile.artifact_repo_id is None:
raise
upsampler_path = resolve_ltx_artifact(
model,
profile.artifact_repo_id,
profile.latent_upsampler_filename,
)
pipeline.latent_upsampler = _load_ltx_latent_upsampler_single_file(upsampler_path, dtype)
else:
if profile.latent_upsampler_filename is None or profile.artifact_repo_id is None:
raise FileNotFoundError(f"LTX latent upsampler component not found under {model}.")
upsampler_path = resolve_ltx_artifact(
model, model,
profile.artifact_repo_id, "latent_upsampler",
profile.latent_upsampler_filename, local_files_only=local_files_only,
dtype=dtype,
) )
pipeline.latent_upsampler = _load_ltx_latent_upsampler_single_file(upsampler_path, dtype)
transformer_config = load_transformer_config(model, "transformer", local_files_only) transformer_config = load_transformer_config(model, "transformer", local_files_only)
quant_config = getattr(od_config, "quantization_config", None) quant_config = getattr(od_config, "quantization_config", None)
@@ -517,16 +412,6 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None:
subfolder="scheduler", subfolder="scheduler",
local_files_only=local_files_only, local_files_only=local_files_only,
) )
expected_checkpoint_kind: LTXCheckpointKind | None = None
if pipeline.pipeline_kind == "two_stage":
expected_checkpoint_kind = "regular"
elif pipeline.pipeline_kind == "distilled_two_stage":
expected_checkpoint_kind = "distilled"
validate_ltx_checkpoint(
pipeline.scheduler.config,
expected_kind=expected_checkpoint_kind,
pipeline_name=type(pipeline).__name__,
)
pipeline.vae_spatial_compression_ratio = pipeline.vae.spatial_compression_ratio pipeline.vae_spatial_compression_ratio = pipeline.vae.spatial_compression_ratio
pipeline.vae_temporal_compression_ratio = pipeline.vae.temporal_compression_ratio pipeline.vae_temporal_compression_ratio = pipeline.vae.temporal_compression_ratio
@@ -62,14 +62,13 @@ class LTXDenoiseContext:
audio_latents: torch.Tensor audio_latents: torch.Tensor
video_coords: torch.Tensor video_coords: torch.Tensor
audio_coords: torch.Tensor audio_coords: torch.Tensor
audio_attention_mask: torch.Tensor | None = None
conditioning_mask: torch.Tensor | None = None conditioning_mask: torch.Tensor | None = None
conditioning_mask_for_model: torch.Tensor | None = None conditioning_mask_for_model: torch.Tensor | None = None
@dataclass @dataclass
class LTXPhaseResult: class LTXPhaseResult:
"""Denoised AV latents and the context used to produce them.""" """Denoised, unpacked AV latents and the context used to produce them."""
forward_context: LTXForwardContext forward_context: LTXForwardContext
video: torch.Tensor video: torch.Tensor
@@ -285,8 +284,7 @@ def prepare_rope_coords_stage(
latents: torch.Tensor, latents: torch.Tensor,
audio_latents: torch.Tensor, audio_latents: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]: ) -> tuple[torch.Tensor, torch.Tensor]:
transformer = pipeline.transformer video_coords = pipeline.transformer.rope.prepare_video_coords(
video_coords = transformer.rope.prepare_video_coords(
latents.shape[0], latents.shape[0],
forward_ctx.latent_num_frames, forward_ctx.latent_num_frames,
forward_ctx.latent_height, forward_ctx.latent_height,
@@ -294,7 +292,7 @@ def prepare_rope_coords_stage(
latents.device, latents.device,
fps=forward_ctx.request_inputs.frame_rate, fps=forward_ctx.request_inputs.frame_rate,
) )
audio_coords = transformer.audio_rope.prepare_audio_coords( audio_coords = pipeline.transformer.audio_rope.prepare_audio_coords(
audio_latents.shape[0], audio_latents.shape[0],
forward_ctx.padded_audio_num_frames, forward_ctx.padded_audio_num_frames,
audio_latents.device, audio_latents.device,
@@ -333,7 +331,6 @@ def build_transformer_kwargs(
# with a learned register, making all output context tokens valid. # with a learned register, making all output context tokens valid.
"encoder_attention_mask": None, "encoder_attention_mask": None,
"audio_encoder_attention_mask": None, "audio_encoder_attention_mask": None,
"audio_attention_mask": denoise_ctx.audio_attention_mask,
"num_frames": forward_ctx.latent_num_frames, "num_frames": forward_ctx.latent_num_frames,
"height": forward_ctx.latent_height, "height": forward_ctx.latent_height,
"width": forward_ctx.latent_width, "width": forward_ctx.latent_width,
@@ -445,25 +442,11 @@ class LTXPhaseExecutor:
video_audio_step_adapter=video_audio_step_adapter, video_audio_step_adapter=video_audio_step_adapter,
) )
video_coords, audio_coords = prepare_rope_coords_stage(pipeline, forward_ctx, latents, audio_latents) video_coords, audio_coords = prepare_rope_coords_stage(pipeline, forward_ctx, latents, audio_latents)
ring_degree = getattr(pipeline.od_config.parallel_config, "ring_degree", 1) or 1
if padded_audio_num_frames > original_audio_num_frames and ring_degree > 1:
raise ValueError(
"LTX audio padding requires an attention mask, which Ring sequence parallelism does not support. "
"Use Ulysses-only SP or choose a request whose audio latent length is divisible by the SP size."
)
denoise_ctx = LTXDenoiseContext( denoise_ctx = LTXDenoiseContext(
latents=latents, latents=latents,
audio_latents=audio_latents, audio_latents=audio_latents,
video_coords=video_coords, video_coords=video_coords,
audio_coords=audio_coords, audio_coords=audio_coords,
audio_attention_mask=(
torch.arange(padded_audio_num_frames, device=audio_latents.device)
.lt(original_audio_num_frames)
.unsqueeze(0)
.expand(audio_latents.shape[0], -1)
if padded_audio_num_frames > original_audio_num_frames
else None
),
conditioning_mask=conditioning_mask, conditioning_mask=conditioning_mask,
) )
denoise_ctx = pipeline._prepare_denoise_context_for_guidance(forward_ctx, denoise_ctx) denoise_ctx = pipeline._prepare_denoise_context_for_guidance(forward_ctx, denoise_ctx)
@@ -126,10 +126,7 @@ def x0_from_velocity(sample: torch.Tensor, velocity: torch.Tensor, sigma: torch.
def velocity_from_x0(sample: torch.Tensor, x0: torch.Tensor, sigma: torch.Tensor) -> torch.Tensor: def velocity_from_x0(sample: torch.Tensor, x0: torch.Tensor, sigma: torch.Tensor) -> torch.Tensor:
"""Convert x0 back to velocity using the official fp32 arithmetic order.""" """Convert x0 back to velocity using the official fp32 arithmetic order."""
# Official LTX converts the scalar scheduler sigma to a host value before sigma = sigma.to(torch.float32)
# division. A CUDA scalar tensor selects different kernel arithmetic and
# can cross bf16 rounding boundaries late in the denoise trajectory.
sigma = sigma.to(torch.float32).item()
return ((sample.to(torch.float32) - x0.to(torch.float32)) / sigma).to(sample.dtype) return ((sample.to(torch.float32) - x0.to(torch.float32)) / sigma).to(sample.dtype)
@@ -151,7 +148,6 @@ def combine_guided_x0(
uncond_perturbed: torch.Tensor | float, uncond_perturbed: torch.Tensor | float,
uncond_modality: torch.Tensor | float, uncond_modality: torch.Tensor | float,
guidance: LTXModalityGuidance, guidance: LTXModalityGuidance,
rescale_token_count: int | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
dtype = cond.dtype dtype = cond.dtype
cond = cond.float() cond = cond.float()
@@ -167,18 +163,9 @@ def combine_guided_x0(
# uses its full-tensor standard deviation. In Omni, dimension 0 may # uses its full-tensor standard deviation. In Omni, dimension 0 may
# instead contain independently scheduled requests. Reduce each item # instead contain independently scheduled requests. Reduce each item
# separately so its result cannot depend on unrelated co-batched work. # separately so its result cannot depend on unrelated co-batched work.
stats_cond = cond reduce_dims = tuple(range(1, pred.ndim))
stats_pred = pred cond_std = cond.std(dim=reduce_dims, keepdim=True)
if rescale_token_count is not None: pred_std = pred.std(dim=reduce_dims, keepdim=True)
if cond.ndim < 2 or not 0 < rescale_token_count <= cond.shape[1]:
raise ValueError(
f"Guidance rescale token count must be in [1, {cond.shape[1]}], got {rescale_token_count}."
)
stats_cond = cond[:, :rescale_token_count]
stats_pred = pred[:, :rescale_token_count]
reduce_dims = tuple(range(1, stats_pred.ndim))
cond_std = stats_cond.std(dim=reduce_dims, keepdim=True)
pred_std = stats_pred.std(dim=reduce_dims, keepdim=True)
eps = torch.finfo(pred.dtype).eps eps = torch.finfo(pred.dtype).eps
factor = cond_std / pred_std.clamp_min(eps) factor = cond_std / pred_std.clamp_min(eps)
factor = torch.where(pred_std > eps, factor, torch.ones_like(factor)) factor = torch.where(pred_std > eps, factor, torch.ones_like(factor))
@@ -188,8 +175,6 @@ def combine_guided_x0(
def _repeat_batch(tensor: torch.Tensor, repeats: int) -> torch.Tensor: def _repeat_batch(tensor: torch.Tensor, repeats: int) -> torch.Tensor:
if repeats == 1:
return tensor
return tensor.repeat((repeats,) + (1,) * (tensor.ndim - 1)) return tensor.repeat((repeats,) + (1,) * (tensor.ndim - 1))
@@ -293,11 +278,6 @@ class LTXGuidanceExecutor:
if model_pass_count > 1: if model_pass_count > 1:
denoise_ctx.video_coords = _repeat_batch(denoise_ctx.video_coords, model_pass_count) denoise_ctx.video_coords = _repeat_batch(denoise_ctx.video_coords, model_pass_count)
denoise_ctx.audio_coords = _repeat_batch(denoise_ctx.audio_coords, model_pass_count) denoise_ctx.audio_coords = _repeat_batch(denoise_ctx.audio_coords, model_pass_count)
if denoise_ctx.audio_attention_mask is not None:
denoise_ctx.audio_attention_mask = _repeat_batch(
denoise_ctx.audio_attention_mask,
model_pass_count,
)
return denoise_ctx return denoise_ctx
@staticmethod @staticmethod
@@ -342,7 +322,6 @@ class LTXGuidanceExecutor:
guidance: LTXModalityGuidance, guidance: LTXModalityGuidance,
*, *,
model_sigma: torch.Tensor | None = None, model_sigma: torch.Tensor | None = None,
rescale_token_count: int | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
model_sigma = sigma if model_sigma is None else model_sigma model_sigma = sigma if model_sigma is None else model_sigma
# Official guidance reduces contiguous BSC tensors. The fused # Official guidance reduces contiguous BSC tensors. The fused
@@ -355,7 +334,6 @@ class LTXGuidanceExecutor:
uncond_perturbed=x0.get("ptb", 0.0), uncond_perturbed=x0.get("ptb", 0.0),
uncond_modality=x0.get("mod", 0.0), uncond_modality=x0.get("mod", 0.0),
guidance=guidance, guidance=guidance,
rescale_token_count=rescale_token_count,
) )
return velocity_from_x0(sample, guided, sigma) return velocity_from_x0(sample, guided, sigma)
@@ -456,7 +434,6 @@ class LTXGuidanceExecutor:
audio_splits, audio_splits,
forward_ctx.audio_scheduler.sigmas[index], forward_ctx.audio_scheduler.sigmas[index],
plan.spec.audio, plan.spec.audio,
rescale_token_count=forward_ctx.original_audio_num_frames,
), ),
) )
@@ -532,7 +509,6 @@ class LTXGuidanceExecutor:
audio_splits, audio_splits,
forward_ctx.audio_scheduler.sigmas[index], forward_ctx.audio_scheduler.sigmas[index],
plan.spec.audio, plan.spec.audio,
rescale_token_count=forward_ctx.original_audio_num_frames,
), ),
) )
+14 -36
View File
@@ -117,9 +117,7 @@ def create_noised_state(
device=latents.device, device=latents.device,
dtype=latents.dtype, dtype=latents.dtype,
) )
if isinstance(noise_scale, torch.Tensor): return noise_scale * noise + (1 - noise_scale) * latents
noise_scale = noise_scale.float()
return torch.lerp(latents.float(), noise.float(), noise_scale).to(latents.dtype)
def pack_audio_latents(latents: torch.Tensor) -> torch.Tensor: def pack_audio_latents(latents: torch.Tensor) -> torch.Tensor:
@@ -137,16 +135,6 @@ def unpad_audio_latents(latents: torch.Tensor, num_frames: int) -> torch.Tensor:
return latents[:, :num_frames] return latents[:, :num_frames]
def clear_audio_padding(latents: torch.Tensor, num_frames: int) -> torch.Tensor:
"""Keep Omni's SP-only audio padding outside the sampler state."""
if not 0 < num_frames <= latents.shape[1]:
raise ValueError(f"Audio frame count must be in [1, {latents.shape[1]}], got {num_frames}.")
if num_frames == latents.shape[1]:
return latents
padding = latents.new_zeros(latents.shape[0], latents.shape[1] - num_frames, latents.shape[2])
return torch.cat([latents[:, :num_frames], padding], dim=1)
def get_sp_padded_audio_latent_length(audio_latent_length: int, sp_size: int) -> int: def get_sp_padded_audio_latent_length(audio_latent_length: int, sp_size: int) -> int:
if sp_size > 1: if sp_size > 1:
audio_latent_length += (sp_size - (audio_latent_length % sp_size)) % sp_size audio_latent_length += (sp_size - (audio_latent_length % sp_size)) % sp_size
@@ -217,12 +205,7 @@ def prepare_video_latents(
f"You have passed a list of generators of length {len(generator)}, but requested an effective batch" 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." 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) return randn_tensor(shape, generator=generator, device=device, dtype=dtype)
# Official LTX samples the 5D video latent first and then patchifies it,
# leaving token-major storage behind the [B, tokens, channels] view. The
# logical values are unchanged, but preserving that layout is important:
# CUDA selects a different bf16 GEMM path for a contiguous token tensor.
return latents.transpose(1, 2).contiguous().transpose(1, 2)
def prepare_audio_latents( def prepare_audio_latents(
@@ -243,16 +226,6 @@ def prepare_audio_latents(
sp_size = getattr(pipeline.od_config.parallel_config, "sequence_parallel_size", 1) or 1 sp_size = getattr(pipeline.od_config.parallel_config, "sequence_parallel_size", 1) or 1
padded_latent_length = get_sp_padded_audio_latent_length(original_latent_length, int(sp_size)) padded_latent_length = get_sp_padded_audio_latent_length(original_latent_length, int(sp_size))
def pad_logical_latents(logical_latents: torch.Tensor) -> torch.Tensor:
if padded_latent_length == original_latent_length:
return logical_latents
padding = logical_latents.new_zeros(
logical_latents.shape[0],
padded_latent_length - original_latent_length,
logical_latents.shape[2],
)
return torch.cat([logical_latents, padding], dim=1)
if latents is not None: if latents is not None:
if latents.ndim == 4: if latents.ndim == 4:
latents = pack_audio_latents(latents) latents = pack_audio_latents(latents)
@@ -264,24 +237,29 @@ def prepare_audio_latents(
pipeline.audio_vae.latents_mean, pipeline.audio_vae.latents_mean,
pipeline.audio_vae.latents_std, pipeline.audio_vae.latents_std,
) )
latents = create_noised_state(latents, noise_scale, generator)
if latents.shape[1] not in {original_latent_length, padded_latent_length}: if latents.shape[1] not in {original_latent_length, padded_latent_length}:
raise ValueError( raise ValueError(
"Provided `audio_latents` has incompatible audio frame count " "Provided `audio_latents` has incompatible audio frame count "
f"{latents.shape[1]}; expected {original_latent_length} or {padded_latent_length}." f"{latents.shape[1]}; expected {original_latent_length} or {padded_latent_length}."
) )
# Padding is an Omni implementation detail, not part of the official if latents.shape[1] == original_latent_length and padded_latent_length > original_latent_length:
# audio state. Draw request RNG only for logical tokens so later phases padding = torch.zeros(
# remain seed-invariant across SP degrees, then append deterministic 0s. latents.shape[0],
latents = create_noised_state(latents[:, :original_latent_length], noise_scale, generator) padded_latent_length - original_latent_length,
latents = pad_logical_latents(latents) latents.shape[2],
dtype=latents.dtype,
device=latents.device,
)
latents = torch.cat([latents, padding], dim=1)
return latents.to(device=device, dtype=dtype), original_latent_length, padded_latent_length return latents.to(device=device, dtype=dtype), original_latent_length, padded_latent_length
shape = (batch_size, original_latent_length, num_channels_latents * latent_mel_bins) shape = (batch_size, padded_latent_length, num_channels_latents * latent_mel_bins)
if isinstance(generator, list) and len(generator) != batch_size: if isinstance(generator, list) and len(generator) != batch_size:
raise ValueError( raise ValueError(
f"You have passed a list of generators of length {len(generator)}, but requested an effective batch" 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." f" size of {batch_size}. Make sure the batch size matches the length of the generators."
) )
latents = pad_logical_latents(randn_tensor(shape, generator=generator, device=device, dtype=dtype)) latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype)
return latents, original_latent_length, padded_latent_length return latents, original_latent_length, padded_latent_length
@@ -1,430 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Construction and execution of a fixed LTX refinement-phase adapter.
Checkpoint key parsing remains in :mod:`ltx2_adapter_parser`; this module
resolves the declared artifact and makes the existing Transformer linears
adapter-capable while retaining their quantization and tensor-parallel paths.
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
import vllm.envs as envs
from vllm.distributed import (
split_tensor_along_last_dim,
tensor_model_parallel_all_reduce,
)
from vllm.logger import init_logger
from vllm.model_executor.layers.batch_invariant import linear_batch_invariant
from vllm.model_executor.layers.linear import (
ColumnParallelLinear,
QKVParallelLinear,
RowParallelLinear,
UnquantizedLinearMethod,
)
from vllm.model_executor.layers.utils import dispatch_unquantized_gemm
from vllm.platforms import current_platform
from .ltx2_adapter_parser import AdapterManifest, AdapterTarget, iter_adapter_tensors, parse_ltx_adapter
from .ltx2_components import resolve_ltx_artifact
from .ltx2_recipes import LTX_DISTILLED_ADAPTER_SLOT
logger = init_logger(__name__)
@dataclass(frozen=True)
class _AdapterLayout:
"""Rank-local storage and execution layout for one LoRA pair."""
kind: str
input_size: int
output_size: int
a_input_start: int
a_input_size: int
b_output_start: int
b_output_size: int
output_start: int
reduce_results: bool = False
def _module_device(module: nn.Module) -> torch.device:
for tensor in (*module.parameters(), *module.buffers()):
if tensor.device.type != "meta":
return tensor.device
raise RuntimeError(f"Cannot materialize an LTX phase adapter for meta module {module._get_name()}.")
def _build_layout(layer: nn.Module, target: AdapterTarget) -> _AdapterLayout:
if isinstance(layer, QKVParallelLinear):
if target.slice not in {"q", "k", "v"}:
raise ValueError(
f"LTX packed QKV target {target.module!r} requires a q/k/v adapter slice, got {target.slice!r}."
)
q_local = layer.num_heads * layer.head_size
k_local = layer.num_kv_heads * layer.head_size
v_local = layer.num_kv_heads * layer.v_head_size
if target.slice == "q":
output_size = layer.total_num_heads * layer.head_size
b_output_start = layer.tp_rank * q_local
b_output_size = q_local
output_start = 0
elif target.slice == "k":
output_size = layer.total_num_kv_heads * layer.head_size
shard_rank = layer.tp_rank // layer.num_kv_head_replicas
b_output_start = shard_rank * k_local
b_output_size = k_local
output_start = q_local
else:
output_size = layer.total_num_kv_heads * layer.v_head_size
shard_rank = layer.tp_rank // layer.num_kv_head_replicas
b_output_start = shard_rank * v_local
b_output_size = v_local
output_start = q_local + k_local
return _AdapterLayout(
kind="qkv",
input_size=layer.input_size,
output_size=output_size,
a_input_start=0,
a_input_size=layer.input_size,
b_output_start=b_output_start,
b_output_size=b_output_size,
output_start=output_start,
)
if isinstance(layer, ColumnParallelLinear):
if layer.gather_output:
raise ValueError(
f"LTX phase adapter does not support gathered ColumnParallelLinear target {target.module!r}."
)
return _AdapterLayout(
kind="column",
input_size=layer.input_size,
output_size=layer.output_size,
a_input_start=0,
a_input_size=layer.input_size,
b_output_start=layer.tp_rank * layer.output_size_per_partition,
b_output_size=layer.output_size_per_partition,
output_start=0,
)
if isinstance(layer, RowParallelLinear):
return _AdapterLayout(
kind="row",
input_size=layer.input_size,
output_size=layer.output_size,
a_input_start=layer.tp_rank * layer.input_size_per_partition,
a_input_size=layer.input_size_per_partition,
b_output_start=0,
b_output_size=layer.output_size,
output_start=0,
reduce_results=layer.reduce_results,
)
if isinstance(layer, nn.Linear):
return _AdapterLayout(
kind="replicated",
input_size=layer.in_features,
output_size=layer.out_features,
a_input_start=0,
a_input_size=layer.in_features,
b_output_start=0,
b_output_size=layer.out_features,
output_start=0,
)
raise TypeError(f"LTX phase adapter target {target.module!r} has unsupported layer type {type(layer).__name__}.")
class _AdapterPiece(nn.Module):
"""One rank-local A/B pair stored as non-persistent buffers."""
def __init__(self, target: AdapterTarget, layout: _AdapterLayout) -> None:
super().__init__()
self.target = target
self.layout = layout
# The fixed graph is installed before base checkpoint loading. The
# empty buffers are replaced only after the base layer's quant/TP layout
# has settled and before offload/compile snapshots are taken.
self.register_buffer("lora_a", torch.empty(0), persistent=False)
self.register_buffer("lora_b", torch.empty(0), persistent=False)
def load(self, lora_a: torch.Tensor, lora_b: torch.Tensor, *, device: torch.device, dtype: torch.dtype) -> None:
if tuple(lora_a.shape) != self.target.a_shape or tuple(lora_b.shape) != self.target.b_shape:
raise ValueError(
f"LTX adapter source shape changed for {self.target.source_module!r}: "
f"A={tuple(lora_a.shape)}, B={tuple(lora_b.shape)}."
)
if lora_a.ndim != 2 or lora_b.ndim != 2:
raise ValueError(f"LTX adapter target {self.target.source_module!r} must use rank-2 A/B tensors.")
rank = self.target.a_shape[0]
if lora_a.shape[1] != self.layout.input_size or lora_b.shape[0] != self.layout.output_size:
raise ValueError(
f"LTX adapter shape does not match {self.target.module!r}: "
f"expected A=({rank}, {self.layout.input_size}), "
f"B=({self.layout.output_size}, {rank}); "
f"got A={tuple(lora_a.shape)}, B={tuple(lora_b.shape)}."
)
local_a = lora_a.narrow(1, self.layout.a_input_start, self.layout.a_input_size)
local_b = lora_b.narrow(0, self.layout.b_output_start, self.layout.b_output_size)
self.lora_a = local_a.to(device=device, dtype=dtype)
self.lora_b = local_b.to(device=device, dtype=dtype)
def delta(self, input_: torch.Tensor) -> torch.Tensor:
if self.lora_a.numel() == 0 or self.lora_b.numel() == 0:
raise RuntimeError(f"LTX phase adapter target {self.target.module!r} was not materialized.")
if self.layout.kind == "row" and input_.shape[-1] != self.layout.a_input_size:
input_ = input_.narrow(-1, self.layout.a_input_start, self.layout.a_input_size)
input_ = input_.to(dtype=self.lora_a.dtype)
delta = F.linear(F.linear(input_, self.lora_a), self.lora_b)
if self.layout.kind == "row" and self.layout.reduce_results:
delta = tensor_model_parallel_all_reduce(delta)
return delta
def weight_delta(self) -> torch.Tensor:
"""Materialize the rank-local BF16 weight delta used by official fusion."""
if self.lora_a.numel() == 0 or self.lora_b.numel() == 0:
raise RuntimeError(f"LTX phase adapter target {self.target.module!r} was not materialized.")
return torch.matmul(self.lora_b, self.lora_a)
def _apply_unquantized_gemm_with_weight(
layer: nn.Module,
input_: torch.Tensor,
weight: torch.Tensor,
bias: torch.Tensor | None,
) -> torch.Tensor:
if envs.VLLM_BATCH_INVARIANT and current_platform.is_cuda_alike():
return linear_batch_invariant(input_, weight, bias)
return dispatch_unquantized_gemm()(layer, input_, weight, bias)
def _forward_with_weight(
layer: nn.Module,
input_: torch.Tensor,
weight: torch.Tensor,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor | None]:
"""Run an unquantized base layer with an ephemeral fused weight."""
if isinstance(layer, nn.Linear):
return F.linear(input_, weight, layer.bias)
quant_method = getattr(layer, "quant_method", None)
if not isinstance(quant_method, UnquantizedLinearMethod):
raise ValueError("LTX layer-fused phase LoRA requires unquantized Transformer linears.")
if isinstance(layer, RowParallelLinear):
if layer.input_is_parallel:
input_parallel = input_
else:
input_parallel = split_tensor_along_last_dim(input_, num_partitions=layer.tp_size)[
layer.tp_rank
].contiguous()
bias = None if layer.tp_rank > 0 or layer.skip_bias_add else layer.bias
output_parallel = _apply_unquantized_gemm_with_weight(layer, input_parallel, weight, bias)
if layer.reduce_results and layer.tp_size > 1:
output = tensor_model_parallel_all_reduce(output_parallel)
else:
output = output_parallel
elif isinstance(layer, ColumnParallelLinear):
bias = layer.bias if not layer.skip_bias_add else None
output = _apply_unquantized_gemm_with_weight(layer, input_, weight, bias)
else:
raise TypeError(f"Unsupported LTX layer-fused linear type {type(layer).__name__}.")
if not layer.return_bias:
return output
return output, layer.bias if layer.skip_bias_add else None
class _PhaseAdapterLinear(nn.Module):
"""Delegate the base linear unchanged and add an enabled adapter slot."""
def __init__(
self,
base_layer: nn.Module,
targets: Iterable[AdapterTarget],
*,
adapter_dtype: torch.dtype,
layer_fused: bool = False,
) -> None:
super().__init__()
self.base_layer = base_layer
self.adapter_dtype = adapter_dtype
if layer_fused and not (
isinstance(base_layer, nn.Linear)
or isinstance(getattr(base_layer, "quant_method", None), UnquantizedLinearMethod)
):
raise ValueError("LTX layer-fused phase LoRA requires unquantized Transformer linears.")
self.layer_fused = layer_fused
pieces = [_AdapterPiece(target, _build_layout(base_layer, target)) for target in targets]
self.pieces = nn.ModuleList(pieces)
self._pieces_by_target = {piece.target: piece for piece in pieces}
self._enabled = False
def load_target(self, target: AdapterTarget, lora_a: torch.Tensor, lora_b: torch.Tensor) -> None:
try:
piece = self._pieces_by_target[target]
except KeyError as exc:
raise ValueError(f"Adapter target {target.module!r} is not installed on this layer.") from exc
piece.load(lora_a, lora_b, device=_module_device(self.base_layer), dtype=self.adapter_dtype)
def set_enabled(self, enabled: bool) -> None:
self._enabled = enabled
def _add_adapter_delta(self, input_: torch.Tensor, output: torch.Tensor) -> torch.Tensor:
if not self._enabled:
return output
if output.requires_grad:
output = output.clone()
for piece in self.pieces:
delta = piece.delta(input_).to(dtype=output.dtype)
start = piece.layout.output_start
output[..., start : start + piece.layout.b_output_size].add_(delta)
return output
@torch.no_grad()
def _fused_weight(self) -> torch.Tensor:
base_weight = self.base_layer.weight
fused_weight = base_weight.clone()
for piece in self.pieces:
start = piece.layout.output_start
base_slice = base_weight.narrow(0, start, piece.layout.b_output_size)
delta = piece.weight_delta()
if delta.shape != base_slice.shape:
raise ValueError(
f"LTX layer-fused delta shape {tuple(delta.shape)} does not match base weight slice "
f"{tuple(base_slice.shape)} for {piece.target.module!r}."
)
# Match official fusion exactly: materialize B@A in the adapter
# dtype, then add the base tensor in-place before the main GEMM.
delta.add_(base_slice)
fused_weight.narrow(0, start, piece.layout.b_output_size).copy_(delta)
return fused_weight
def forward(self, input_: torch.Tensor):
if not self._enabled:
return self.base_layer(input_)
if self.layer_fused:
return _forward_with_weight(self.base_layer, input_, self._fused_weight())
output = self.base_layer(input_)
if isinstance(output, tuple):
return (self._add_adapter_delta(input_, output[0]), *output[1:])
return self._add_adapter_delta(input_, output)
class LTXPhaseAdapterRuntime:
"""Install and operate one fixed phase adapter on an existing transformer."""
def __init__(
self,
transformer: nn.Module,
manifest: AdapterManifest,
*,
dtype: torch.dtype,
layer_fused: bool = False,
) -> None:
self.transformer = transformer
self.manifest = manifest
self._wrappers: dict[str, _PhaseAdapterLinear] = {}
self._target_names: tuple[str, ...] = ()
self._materialized = False
grouped: dict[str, list[AdapterTarget]] = defaultdict(list)
for target in manifest.targets:
grouped[target.module].append(target)
original_layers = {name: self.transformer.get_submodule(name) for name in grouped}
for name, targets in grouped.items():
wrapper = _PhaseAdapterLinear(
original_layers[name],
targets,
adapter_dtype=dtype,
layer_fused=layer_fused,
)
parent_name, _, child_name = name.rpartition(".")
parent = self.transformer.get_submodule(parent_name) if parent_name else self.transformer
if child_name not in parent._modules:
raise ValueError(f"LTX phase adapter target {name!r} is no longer a direct module child.")
parent._modules[child_name] = wrapper
self._wrappers[name] = wrapper
self._target_names = tuple(sorted(self._wrappers, key=len, reverse=True))
# LTX's custom loader receives the original checkpoint names. Keep the
# adaptation local to that loader rather than changing AutoWeightsLoader.
self.transformer._phase_adapter_parameter_name = self.base_parameter_name
logger.info(
"Installed %d LTX %s phase-adapter linears",
len(self._wrappers),
"layer-fused" if layer_fused else "dynamic",
)
def base_parameter_name(self, name: str) -> str:
for target_name in self._target_names:
prefix = f"{target_name}."
if name.startswith(prefix):
return f"{target_name}.base_layer.{name[len(prefix) :]}"
return name
def finalize(self) -> None:
"""Stream rank-local A/B buffers after base weight processing completes."""
if self._materialized:
return
for target, lora_a, lora_b in iter_adapter_tensors(self.manifest):
self._wrappers[target.module].load_target(target, lora_a, lora_b)
self._materialized = True
logger.info("Materialized %d LTX phase-adapter tensor pairs", len(self.manifest.targets))
def activate(self, adapter_slot: str | None) -> None:
if adapter_slot is not None:
if adapter_slot != LTX_DISTILLED_ADAPTER_SLOT:
raise ValueError(f"Unknown LTX phase adapter slot {adapter_slot!r}.")
if not self._materialized:
raise RuntimeError("LTX phase adapter data must be materialized before activation.")
for wrapper in self._wrappers.values():
wrapper.set_enabled(adapter_slot is not None)
def build_ltx_phase_adapter(pipeline: Any) -> LTXPhaseAdapterRuntime | None:
"""Build the fixed adapter required by a multi-phase LTX recipe."""
adapter_slots = {phase.adapter_slot for phase in pipeline.pipeline_recipe.phases if phase.adapter_slot is not None}
if not adapter_slots:
return None
if adapter_slots != {LTX_DISTILLED_ADAPTER_SLOT}:
raise ValueError(f"Unsupported LTX phase adapter slots: {sorted(adapter_slots)}.")
profile = pipeline.component_profile
if profile.distilled_lora_filename is None or profile.artifact_repo_id is None:
raise ValueError(f"{profile.name} does not declare the required distilled adapter artifact.")
if getattr(pipeline.od_config, "lora_path", None) is not None:
raise ValueError(
f"{pipeline.__class__.__name__} reserves LoRA execution for its phase adapter; "
"request or static LoRA composition is not supported yet."
)
quantization_config = getattr(pipeline.od_config, "quantization_config", None)
layer_fused = quantization_config is None
adapter_path = resolve_ltx_artifact(
pipeline.od_config.model,
profile.artifact_repo_id,
profile.distilled_lora_filename,
)
if layer_fused and pipeline.od_config.dtype is not torch.bfloat16:
raise ValueError("LTX layer-fused phase LoRA requires a bfloat16 pipeline dtype.")
manifest = parse_ltx_adapter(pipeline.transformer, adapter_path)
runtime = LTXPhaseAdapterRuntime(
pipeline.transformer,
manifest,
dtype=pipeline.od_config.dtype,
layer_fused=layer_fused,
)
return runtime
@@ -24,7 +24,6 @@ LTX_DEFAULT_NEGATIVE_PROMPT = (
LTX_DISTILLED_SIGMAS = (1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0) LTX_DISTILLED_SIGMAS = (1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0)
LTX_STAGE_2_DISTILLED_SIGMAS = (0.909375, 0.725, 0.421875, 0.0) LTX_STAGE_2_DISTILLED_SIGMAS = (0.909375, 0.725, 0.421875, 0.0)
LTX_DISTILLED_ADAPTER_SLOT = "ltx_distilled"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -37,7 +36,6 @@ class LTXPhaseRecipe:
sigmas: tuple[float, ...] | None = None sigmas: tuple[float, ...] | None = None
noise_scale: float = 0.0 noise_scale: float = 0.0
input_transform: Literal["initial", "spatial_upsample"] = "initial" input_transform: Literal["initial", "spatial_upsample"] = "initial"
adapter_slot: str | None = None
allow_guidance_override: bool = True allow_guidance_override: bool = True
use_official_sigma_schedule: bool = True use_official_sigma_schedule: bool = True
@@ -159,59 +157,11 @@ LTX2_DISTILLED_TWO_STAGE_RECIPE = LTXPipelineRecipe(
fixed_num_inference_steps=True, fixed_num_inference_steps=True,
) )
# LTX-2.3 full-distilled uses the same official fixed 8 + 3 sigma schedules
# and request contract. Its distinct component profile selects the 22B merged
# distilled Transformer, BWE vocoder, and matching spatial upsampler; no
# distilled LoRA is loaded for either phase.
LTX23_DISTILLED_TWO_STAGE_RECIPE = LTX2_DISTILLED_TWO_STAGE_RECIPE
def _official_two_stage_recipe(one_stage_recipe: LTXPipelineRecipe) -> LTXPipelineRecipe:
return LTXPipelineRecipe(
height=one_stage_recipe.height * 2,
width=one_stage_recipe.width * 2,
num_frames=one_stage_recipe.num_frames,
frame_rate=one_stage_recipe.frame_rate,
num_inference_steps=one_stage_recipe.num_inference_steps,
negative_prompt=one_stage_recipe.negative_prompt,
phases=(
LTXPhaseRecipe(
name="generate_lowres",
guidance=one_stage_recipe.request_guidance,
spatial_downscale=2,
noise_scale=1.0,
),
LTXPhaseRecipe(
name="refine",
guidance=LTXGuidanceSpec.positive_only(),
sigmas=LTX_STAGE_2_DISTILLED_SIGMAS,
noise_scale=LTX_STAGE_2_DISTILLED_SIGMAS[0],
input_transform="spatial_upsample",
adapter_slot=LTX_DISTILLED_ADAPTER_SLOT,
allow_guidance_override=False,
use_official_sigma_schedule=False,
),
),
video_output_phase=1,
# The official second stage refines video only and deliberately
# discards its audio result. Decode the full-context Stage-1 audio.
audio_output_phase=0,
allow_request_sigmas=False,
allow_request_latents=False,
)
LTX2_TWO_STAGE_RECIPE = _official_two_stage_recipe(LTX2_ONE_STAGE_RECIPE)
LTX23_TWO_STAGE_RECIPE = _official_two_stage_recipe(LTX23_ONE_STAGE_RECIPE)
_PIPELINE_RECIPES: dict[tuple[str, str], LTXPipelineRecipe] = { _PIPELINE_RECIPES: dict[tuple[str, str], LTXPipelineRecipe] = {
("one_stage", "2"): LTX2_ONE_STAGE_RECIPE, ("one_stage", "2"): LTX2_ONE_STAGE_RECIPE,
("one_stage", "2.3"): LTX23_ONE_STAGE_RECIPE, ("one_stage", "2.3"): LTX23_ONE_STAGE_RECIPE,
("two_stage", "2"): LTX2_TWO_STAGE_RECIPE,
("two_stage", "2.3"): LTX23_TWO_STAGE_RECIPE,
("distilled_two_stage", "2"): LTX2_DISTILLED_TWO_STAGE_RECIPE, ("distilled_two_stage", "2"): LTX2_DISTILLED_TWO_STAGE_RECIPE,
("distilled_two_stage", "2.3"): LTX23_DISTILLED_TWO_STAGE_RECIPE,
("dmd2", "2"): LTX_POSITIVE_ONLY_RECIPE, ("dmd2", "2"): LTX_POSITIVE_ONLY_RECIPE,
("dmd2", "2.3"): LTX_POSITIVE_ONLY_RECIPE, ("dmd2", "2.3"): LTX_POSITIVE_ONLY_RECIPE,
} }
@@ -5,9 +5,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import Any, Literal from typing import Any
import torch import torch
@@ -50,34 +49,6 @@ class LTXRequestInputs:
return self.guidance.video.cfg_scale return self.guidance.video.cfg_scale
LTXCheckpointKind = Literal["distilled", "regular"]
def validate_ltx_checkpoint(
scheduler_config: Mapping[str, Any],
*,
expected_kind: LTXCheckpointKind | None,
pipeline_name: str,
) -> None:
"""Ensure scheduler metadata matches the checkpoint required by a profile."""
if expected_kind is None:
return
if expected_kind == "distilled":
if scheduler_config.get("use_dynamic_shifting", True) or scheduler_config.get("shift_terminal") is not None:
raise ValueError(
f"{pipeline_name} requires a merged distilled LTX2 checkpoint; "
"the loaded scheduler metadata describes a non-distilled checkpoint."
)
return
if not scheduler_config.get("use_dynamic_shifting", False) or scheduler_config.get("shift_terminal") is None:
raise ValueError(
f"{pipeline_name} requires a regular non-distilled LTX checkpoint; "
"the loaded scheduler metadata describes a distilled checkpoint."
)
def validate_pipeline_request( def validate_pipeline_request(
request_inputs: LTXRequestInputs, request_inputs: LTXRequestInputs,
*, *,
@@ -47,7 +47,6 @@ from .ltx2_guidance import (
LTXGuidanceExecutor, LTXGuidanceExecutor,
LTXGuidancePlan, LTXGuidancePlan,
) )
from .ltx2_phase_adapter import LTXPhaseAdapterRuntime, build_ltx_phase_adapter
from .ltx2_recipes import ( from .ltx2_recipes import (
LTXPhaseRecipe, LTXPhaseRecipe,
LTXPipelineRecipe, LTXPipelineRecipe,
@@ -138,21 +137,9 @@ class LTXRuntime(
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None: def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None:
del prefix del prefix
parallel_config = getattr(od_config, "parallel_config", None)
if getattr(parallel_config, "ulysses_mode", "strict") == "advanced_uaa":
raise ValueError(
f"{self.__class__.__name__} does not support ulysses_mode='advanced_uaa'. "
"Use the default ulysses_mode='strict' for LTX sequence parallelism."
)
self.model_version = detect_ltx_model_version(od_config.model) self.model_version = detect_ltx_model_version(od_config.model)
self.component_profile = resolve_ltx_component_profile(self.pipeline_kind, self.model_version) self.component_profile = resolve_ltx_component_profile(self.pipeline_kind, self.model_version)
self.pipeline_recipe = resolve_ltx_pipeline_recipe(self.pipeline_kind, self.model_version) self.pipeline_recipe = resolve_ltx_pipeline_recipe(self.pipeline_kind, self.model_version)
if getattr(od_config, "cache_backend", "none") == "cache_dit" and len(self.pipeline_recipe.phases) > 1:
raise ValueError(
f"{self.__class__.__name__} does not support cache_backend='cache_dit'. "
"Cache-DiT currently supports only one-stage LTX recipes; multi-stage recipes require "
"phase-aware cache enable and refresh."
)
self._dit_modules = list(self.component_profile.dit_modules) self._dit_modules = list(self.component_profile.dit_modules)
self._encoder_modules = list(self.component_profile.encoder_modules) self._encoder_modules = list(self.component_profile.encoder_modules)
self._vae_modules = list(self.component_profile.vae_modules) self._vae_modules = list(self.component_profile.vae_modules)
@@ -163,7 +150,6 @@ class LTXRuntime(
super().__init__() super().__init__()
self._guidance_plan = LTXGuidancePlan.build(self.pipeline_recipe.request_guidance) self._guidance_plan = LTXGuidancePlan.build(self.pipeline_recipe.request_guidance)
initialize_pipeline_components(self, od_config) initialize_pipeline_components(self, od_config)
self._phase_adapter: LTXPhaseAdapterRuntime | None = build_ltx_phase_adapter(self)
self.setup_diffusion_pipeline_profiler( self.setup_diffusion_pipeline_profiler(
enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler enable_diffusion_pipeline_profiler=self.od_config.enable_diffusion_pipeline_profiler
) )
@@ -219,8 +205,6 @@ class LTXRuntime(
max_sequence_length=max_sequence_length, max_sequence_length=max_sequence_length,
) )
image = self._resolve_request_image(req, image, request_inputs) image = self._resolve_request_image(req, image, request_inputs)
if image is not None and not self.support_image_input:
raise ValueError(f"{self.__class__.__name__} does not support `image` input.")
request_sigmas = self._resolve_request_sigmas(req, sigmas) request_sigmas = self._resolve_request_sigmas(req, sigmas)
validate_pipeline_request( validate_pipeline_request(
request_inputs, request_inputs,
@@ -230,13 +214,6 @@ class LTXRuntime(
pipeline_name=self.__class__.__name__, pipeline_name=self.__class__.__name__,
request_sigmas=request_sigmas, request_sigmas=request_sigmas,
) )
phase_adapter = getattr(self, "_phase_adapter", None)
if phase_adapter is not None and any(
getattr(sampling, "lora_request", None) is not None for sampling in req.sampling_params_list
):
raise ValueError(
f"{self.__class__.__name__} cannot compose a request LoRA with its internal phase adapter."
)
return self._run_recipe(req, request_inputs, request_sigmas=request_sigmas, image=image) return self._run_recipe(req, request_inputs, request_sigmas=request_sigmas, image=image)
def _run_recipe( def _run_recipe(
@@ -281,20 +258,8 @@ class LTXRuntime(
return self.decode_phase(output_phase) return self.decode_phase(output_phase)
def _enter_phase(self, phase: LTXPhaseRecipe) -> None: def _enter_phase(self, phase: LTXPhaseRecipe) -> None:
"""Hook for a future phase-weight strategy."""
self._active_phase_name = phase.name self._active_phase_name = phase.name
phase_adapter = getattr(self, "_phase_adapter", None)
if phase_adapter is None:
if phase.adapter_slot is not None:
raise RuntimeError(f"LTX phase {phase.name!r} requires adapter slot {phase.adapter_slot!r}.")
return
phase_adapter.activate(phase.adapter_slot)
def eval(self):
result = super().eval()
phase_adapter = getattr(self, "_phase_adapter", None)
if phase_adapter is not None:
phase_adapter.finalize()
return result
def prepare_latents( def prepare_latents(
self, self,
@@ -727,7 +692,6 @@ class LTXRuntime(
noise_pred_audio, noise_pred_audio,
timestep, timestep,
) )
audio = latent_ops.clear_audio_padding(audio, forward_ctx.original_audio_num_frames)
return latent_ops.LTXAVState(video=video, audio=audio) return latent_ops.LTXAVState(video=video, audio=audio)
def _unpack_and_denormalize_stage( def _unpack_and_denormalize_stage(
@@ -355,19 +355,6 @@ def to_ltx_padding_mask(attention_mask: torch.Tensor) -> torch.Tensor:
return attention_mask return attention_mask
class _LTX2ParallelAttention(Attention):
"""Preserve LTX SP collectives while applying model-specific padding masks."""
def _run_local_attention(self, query, key, value, attn_metadata):
attention_mask = attn_metadata.attn_mask if attn_metadata is not None else None
if attention_mask is not None:
# LTX masks describe K/V padding. Keep this semantic local to LTX:
# Flash varlen applies a 2D mask to both Q and K, while SP uses a
# broadcast 4D key mask that only SDPA consumes correctly.
return self.sdpa_fallback.forward(query, key, value, attn_metadata)
return super()._run_local_attention(query, key, value, attn_metadata)
class LTX2AudioVideoAttnProcessor: class LTX2AudioVideoAttnProcessor:
r""" r"""
Processor for implementing attention (SDPA is used by default if you're using PyTorch 2.0) for the LTX-2.0 model. Processor for implementing attention (SDPA is used by default if you're using PyTorch 2.0) for the LTX-2.0 model.
@@ -409,10 +396,11 @@ class LTX2AudioVideoAttnProcessor:
return None return None
if self._is_sp_enabled(): if self._is_sp_enabled():
# Keep the full key mask replicated while strict Ulysses reshards # In SP, Ulysses expects a 2D padding mask that matches query length.
# Q/K/V. The broadcast Q dimension also works when cross-attention # For cross-attention, encoder sequence length != query length, so drop the mask.
# has different Q and K lengths, without changing generic Ulysses. if encoder_hidden_states is not None and encoder_hidden_states.shape[1] != hidden_states.shape[1]:
return to_ltx_padding_mask(attention_mask)[:, None, None, :] return None
return to_ltx_padding_mask(attention_mask)
attention_mask = attn.prepare_attention_mask(attention_mask, sequence_length, batch_size) attention_mask = attn.prepare_attention_mask(attention_mask, sequence_length, batch_size)
attention_mask = attention_mask.view(batch_size, attn.heads, -1, attention_mask.shape[-1]) attention_mask = attention_mask.view(batch_size, attn.heads, -1, attention_mask.shape[-1])
@@ -708,7 +696,7 @@ class LTX2Attention(torch.nn.Module):
torch.nn.Dropout(dropout) if dropout > 0 else torch.nn.Identity(), torch.nn.Dropout(dropout) if dropout > 0 else torch.nn.Identity(),
] ]
) )
self.attn = _LTX2ParallelAttention( self.attn = Attention(
num_heads=self.query_num_heads, num_heads=self.query_num_heads,
head_size=dim_head, head_size=dim_head,
num_kv_heads=self.kv_num_heads, num_kv_heads=self.kv_num_heads,
@@ -721,15 +709,7 @@ class LTX2Attention(torch.nn.Module):
# LTX-2.3: per-head gated attention # LTX-2.3: per-head gated attention
# leave unquantized for this linear # leave unquantized for this linear
if apply_gated_attention: if apply_gated_attention:
self.to_gate_logits = ColumnParallelLinear( self.to_gate_logits = nn.Linear(query_dim, self.query_num_heads, bias=True)
query_dim,
heads,
bias=True,
gather_output=False,
return_bias=False,
quant_config=None,
prefix=f"{prefix}.to_gate_logits" if prefix else "to_gate_logits",
)
else: else:
self.to_gate_logits = None self.to_gate_logits = None
@@ -1518,14 +1498,6 @@ class LTX2VideoTransformer3DModel(nn.Module):
_layerwise_offload_blocks_attrs = ["transformer_blocks"] _layerwise_offload_blocks_attrs = ["transformer_blocks"]
_hsdp_shard_conditions = [is_transformer_block_module] _hsdp_shard_conditions = [is_transformer_block_module]
_sp_plan: dict[str, Any] | None = None _sp_plan: dict[str, Any] | None = None
stacked_params_mapping = (
(".attn1.to_qkv", ".attn1.to_q", "q"),
(".attn1.to_qkv", ".attn1.to_k", "k"),
(".attn1.to_qkv", ".attn1.to_v", "v"),
(".audio_attn1.to_qkv", ".audio_attn1.to_q", "q"),
(".audio_attn1.to_qkv", ".audio_attn1.to_k", "k"),
(".audio_attn1.to_qkv", ".audio_attn1.to_v", "v"),
)
packed_modules_mapping = { packed_modules_mapping = {
"to_qkv": ["to_q", "to_k", "to_v"], "to_qkv": ["to_q", "to_k", "to_v"],
} }
@@ -1856,7 +1828,6 @@ class LTX2VideoTransformer3DModel(nn.Module):
audio_sigma: torch.Tensor | None = None, audio_sigma: torch.Tensor | None = None,
encoder_attention_mask: torch.Tensor | None = None, encoder_attention_mask: torch.Tensor | None = None,
audio_encoder_attention_mask: torch.Tensor | None = None, audio_encoder_attention_mask: torch.Tensor | None = None,
audio_attention_mask: torch.Tensor | None = None,
num_frames: int | None = None, num_frames: int | None = None,
height: int | None = None, height: int | None = None,
width: int | None = None, width: int | None = None,
@@ -1890,8 +1861,6 @@ class LTX2VideoTransformer3DModel(nn.Module):
Optional multiplicative text attention mask of shape `(batch_size, text_seq_len)`. Optional multiplicative text attention mask of shape `(batch_size, text_seq_len)`.
audio_encoder_attention_mask (`torch.Tensor`, *optional*): audio_encoder_attention_mask (`torch.Tensor`, *optional*):
Optional multiplicative text attention mask of shape `(batch_size, text_seq_len)` for audio modeling. Optional multiplicative text attention mask of shape `(batch_size, text_seq_len)` for audio modeling.
audio_attention_mask (`torch.Tensor`, *optional*):
Optional audio-token key padding mask shared by audio self-attention and audio-to-video attention.
num_frames (`int`, *optional*): num_frames (`int`, *optional*):
The number of latent video frames. Used if calculating the video coordinates for RoPE. The number of latent video frames. Used if calculating the video coordinates for RoPE.
height (`int`, *optional*): height (`int`, *optional*):
@@ -2075,8 +2044,6 @@ class LTX2VideoTransformer3DModel(nn.Module):
"ca_audio_rotary_emb": audio_cross_attn_rotary_emb, "ca_audio_rotary_emb": audio_cross_attn_rotary_emb,
"encoder_attention_mask": encoder_attention_mask, "encoder_attention_mask": encoder_attention_mask,
"audio_encoder_attention_mask": audio_encoder_attention_mask, "audio_encoder_attention_mask": audio_encoder_attention_mask,
"audio_self_attention_mask": audio_attention_mask,
"a2v_cross_attention_mask": audio_attention_mask,
"video_self_attention_perturbation_mask": perturbation_mask_for("video_self_attention", block_idx), "video_self_attention_perturbation_mask": perturbation_mask_for("video_self_attention", block_idx),
"audio_self_attention_perturbation_mask": perturbation_mask_for("audio_self_attention", block_idx), "audio_self_attention_perturbation_mask": perturbation_mask_for("audio_self_attention", block_idx),
"a2v_cross_attention_perturbation_mask": perturbation_mask_for("a2v_cross_attention", block_idx), "a2v_cross_attention_perturbation_mask": perturbation_mask_for("a2v_cross_attention", block_idx),
@@ -2130,8 +2097,16 @@ class LTX2VideoTransformer3DModel(nn.Module):
Returns: Returns:
Set of parameter names that were successfully loaded. Set of parameter names that were successfully loaded.
""" """
stacked_params_mapping = [
(".attn1.to_qkv", ".attn1.to_q", "q"),
(".attn1.to_qkv", ".attn1.to_k", "k"),
(".attn1.to_qkv", ".attn1.to_v", "v"),
(".audio_attn1.to_qkv", ".audio_attn1.to_q", "q"),
(".audio_attn1.to_qkv", ".audio_attn1.to_k", "k"),
(".audio_attn1.to_qkv", ".audio_attn1.to_v", "v"),
]
params_dict = dict(self.named_parameters()) params_dict = dict(self.named_parameters())
phase_adapter_parameter_name = getattr(self, "_phase_adapter_parameter_name", None)
tp_size = get_tensor_model_parallel_world_size() tp_size = get_tensor_model_parallel_world_size()
tp_rank = get_tensor_model_parallel_rank() if tp_size > 1 else 0 tp_rank = get_tensor_model_parallel_rank() if tp_size > 1 else 0
loaded_params: set[str] = set() loaded_params: set[str] = set()
@@ -2152,12 +2127,10 @@ class LTX2VideoTransformer3DModel(nn.Module):
return weight return weight
for name, loaded_weight in weights: for name, loaded_weight in weights:
for param_name, weight_name, shard_id in self.stacked_params_mapping: for param_name, weight_name, shard_id in stacked_params_mapping:
if weight_name not in name: if weight_name not in name:
continue continue
packed_name = name.replace(weight_name, param_name) packed_name = name.replace(weight_name, param_name)
if callable(phase_adapter_parameter_name):
packed_name = phase_adapter_parameter_name(packed_name)
if packed_name not in params_dict: if packed_name not in params_dict:
continue continue
name = packed_name name = packed_name
@@ -2166,8 +2139,6 @@ class LTX2VideoTransformer3DModel(nn.Module):
weight_loader(param, loaded_weight, shard_id) weight_loader(param, loaded_weight, shard_id)
break break
else: else:
if callable(phase_adapter_parameter_name):
name = phase_adapter_parameter_name(name)
if name not in params_dict: if name not in params_dict:
logger.warning( logger.warning(
"Skipping transformer weight %s -- not found in model " "Skipping transformer weight %s -- not found in model "
@@ -1,48 +1,27 @@
# SPDX-License-Identifier: Apache-2.0 # SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Two-stage entry points for the LTX model family.""" """Distilled two-stage entry points for the LTX model family."""
from typing import ClassVar from vllm_omni.diffusion.data import OmniDiffusionConfig
from .ltx2_components import ( from .ltx2_components import LTX2_DISTILLED_COMPONENT_PROFILE
LTX2_DISTILLED_COMPONENT_PROFILE,
LTX2_TWO_STAGE_COMPONENT_PROFILE,
)
from .ltx2_components import ( from .ltx2_components import (
get_ltx2_post_process_func as get_ltx2_post_process_func, # noqa: F401 get_ltx2_post_process_func as get_ltx2_post_process_func, # noqa: F401
) )
from .ltx2_conditioning import LTXI2VConditioningMixin from .ltx2_recipes import LTX2_DISTILLED_TWO_STAGE_RECIPE
from .ltx2_recipes import ( from .pipeline_ltx2 import LTX2Pipeline
LTX2_DISTILLED_TWO_STAGE_RECIPE,
LTX2_TWO_STAGE_RECIPE,
)
from .ltx2_runtime import LTXRuntime
class _LTX2TwoStageBase(LTXI2VConditioningMixin, LTXRuntime): class LTX2DistilledPipeline(LTX2Pipeline):
"""Static discovery contract shared by the two-stage public entries.""" """Unified LTX-2 distilled two-stage T2V/I2V entry."""
_dit_modules: ClassVar[list[str]] = list(LTX2_TWO_STAGE_COMPONENT_PROFILE.dit_modules)
_encoder_modules: ClassVar[list[str]] = list(LTX2_TWO_STAGE_COMPONENT_PROFILE.encoder_modules)
_vae_modules: ClassVar[list[str]] = list(LTX2_TWO_STAGE_COMPONENT_PROFILE.vae_modules)
_resident_modules: ClassVar[list[str]] = list(LTX2_TWO_STAGE_COMPONENT_PROFILE.resident_modules)
supports_request_batch = False
support_image_input = True
unified_text_image_entry = True
class LTX2TwoStagePipeline(_LTX2TwoStageBase):
"""Regular checkpoint with low-resolution generation and LoRA refinement."""
pipeline_kind = "two_stage"
component_profile = LTX2_TWO_STAGE_COMPONENT_PROFILE
pipeline_recipe = LTX2_TWO_STAGE_RECIPE
class LTX2DistilledPipeline(_LTX2TwoStageBase):
"""Unified LTX-2/LTX-2.3 full-distilled two-stage T2V/I2V entry."""
pipeline_kind = "distilled_two_stage" pipeline_kind = "distilled_two_stage"
component_profile = LTX2_DISTILLED_COMPONENT_PROFILE component_profile = LTX2_DISTILLED_COMPONENT_PROFILE
pipeline_recipe = LTX2_DISTILLED_TWO_STAGE_RECIPE pipeline_recipe = LTX2_DISTILLED_TWO_STAGE_RECIPE
supports_request_batch = False
support_image_input = True
unified_text_image_entry = True
def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""):
super().__init__(od_config=od_config, prefix=prefix)
@@ -33,7 +33,6 @@ from vllm_omni.diffusion.distributed.sp_plan import (
SequenceParallelInput, SequenceParallelInput,
SequenceParallelOutput, SequenceParallelOutput,
) )
from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope
from vllm_omni.diffusion.layers.norm import RMSNorm from vllm_omni.diffusion.layers.norm import RMSNorm
from vllm_omni.diffusion.layers.rope import RotaryEmbedding from vllm_omni.diffusion.layers.rope import RotaryEmbedding
@@ -238,15 +237,6 @@ class MiniMaxH3Rope(nn.Module):
return torch.cat((half, half), dim=-1) # [S, 96] return torch.cat((half, half), dim=-1) # [S, 96]
def _build_rope_table(freqs: torch.Tensor) -> torch.Tensor:
"""Materialize H3's packed ``[cos(freqs[:48]), sin(freqs[:48])]`` table."""
half = freqs.shape[-1] // 2
return torch.cat(
(torch.cos(freqs[..., :half]), torch.sin(freqs[..., :half])),
dim=-1,
).to(_BF16_DTYPE)
class MiniMaxH3TimeEmbedder(nn.Module): class MiniMaxH3TimeEmbedder(nn.Module):
def __init__( def __init__(
self, self,
@@ -463,7 +453,7 @@ class MiniMaxH3Attention(nn.Module):
self, self,
x: torch.Tensor, x: torch.Tensor,
*, *,
rope_table: torch.Tensor | None, rope_freqs: torch.Tensor | None,
cu_seqlens: torch.Tensor, cu_seqlens: torch.Tensor,
max_seqlen: int, max_seqlen: int,
packed_total: int | None = None, packed_total: int | None = None,
@@ -489,18 +479,11 @@ class MiniMaxH3Attention(nn.Module):
q = q.view(total, self.num_heads, self.head_dim) q = q.view(total, self.num_heads, self.head_dim)
k = k.view(total, self.num_kv_heads, self.head_dim) k = k.view(total, self.num_kv_heads, self.head_dim)
v = v.view(total, self.num_kv_heads, self.head_dim) v = v.view(total, self.num_kv_heads, self.head_dim)
if rope_table is None: q = self.q_norm(q)
q = self.q_norm(q) k = self.k_norm(k)
k = self.k_norm(k) if rope_freqs is not None:
else: q = self._apply_rope(q, rope_freqs)
q, k = fused_qk_norm_rope( k = self._apply_rope(k, rope_freqs)
q,
k,
self.q_norm.weight,
self.k_norm.weight,
rope_table,
self.q_norm.variance_epsilon,
)
# The packed layout uses a second document for alignment padding. # The packed layout uses a second document for alignment padding.
# Local/Ulysses backends unpad it, while Ring keeps aligned rows for # Local/Ulysses backends unpad it, while Ring keeps aligned rows for
@@ -645,7 +628,7 @@ class MiniMaxH3TokenRefinerBlock(nn.Module):
) -> torch.Tensor: ) -> torch.Tensor:
x = x + self.attn( x = x + self.attn(
self.norm1(x), self.norm1(x),
rope_table=None, rope_freqs=None,
cu_seqlens=cu_seqlens, cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
) )
@@ -724,7 +707,7 @@ class MiniMaxH3DiTBlock(nn.Module):
*, *,
t_emb: torch.Tensor, t_emb: torch.Tensor,
combined_indices: torch.Tensor, combined_indices: torch.Tensor,
rope_table: torch.Tensor, rope_freqs: torch.Tensor,
cu_seqlens: torch.Tensor, cu_seqlens: torch.Tensor,
max_seqlen: int, max_seqlen: int,
packed_total: int, packed_total: int,
@@ -752,7 +735,7 @@ class MiniMaxH3DiTBlock(nn.Module):
h = _modulate_scale_shift(h, shift_msa, scale_msa, combined_indices, dtype=_BF16_DTYPE) h = _modulate_scale_shift(h, shift_msa, scale_msa, combined_indices, dtype=_BF16_DTYPE)
h = self.attn( h = self.attn(
h, h,
rope_table=rope_table, rope_freqs=rope_freqs,
cu_seqlens=cu_seqlens, cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
packed_total=packed_total, packed_total=packed_total,
@@ -834,10 +817,10 @@ class MiniMaxH3SPPrepare(nn.Module):
def forward( def forward(
self, self,
hidden_states: torch.Tensor, hidden_states: torch.Tensor,
rope_table: torch.Tensor, rope_freqs: torch.Tensor,
combined_indices: torch.Tensor, combined_indices: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
return hidden_states, rope_table, combined_indices return hidden_states, rope_freqs, combined_indices
class MiniMaxH3SPGather(nn.Module): class MiniMaxH3SPGather(nn.Module):
@@ -1170,8 +1153,8 @@ class MiniMaxH3DiTModel(nn.Module):
if inverse_indices.shape[0] != seq_len: if inverse_indices.shape[0] != seq_len:
raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}") raise ValueError(f"inverse_indices must be [{seq_len}], got {list(inverse_indices.shape)}")
device = x.device device = x.device
# Compute the packed RoPE table once for all transformer blocks. # Compute RoPE frequencies over the full packed sequence.
rope_table = _build_rope_table(self.rope(img_position_ids).to(device)) rope_freqs = self.rope(img_position_ids).to(device)
decoder_input, t_emb = self._embed( decoder_input, t_emb = self._embed(
x=x, x=x,
@@ -1192,7 +1175,7 @@ class MiniMaxH3DiTModel(nn.Module):
hidden = decoder_input hidden = decoder_input
cu_seqlens = cu_seqlens.to(device) cu_seqlens = cu_seqlens.to(device)
block_rope = rope_table block_rope = rope_freqs
block_combined = combined_indices block_combined = combined_indices
hidden, block_rope, block_combined = self.sp_prepare( hidden, block_rope, block_combined = self.sp_prepare(
@@ -1205,7 +1188,7 @@ class MiniMaxH3DiTModel(nn.Module):
hidden, hidden,
t_emb=t_emb, t_emb=t_emb,
combined_indices=block_combined, combined_indices=block_combined,
rope_table=block_rope, rope_freqs=block_rope,
cu_seqlens=cu_seqlens, cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
packed_total=seq_len, packed_total=seq_len,
-6
View File
@@ -71,11 +71,6 @@ _DIFFUSION_MODELS = {
"pipeline_ltx2", "pipeline_ltx2",
"LTX2Pipeline", "LTX2Pipeline",
), ),
"LTX2TwoStagePipeline": (
"ltx2",
"pipeline_ltx2_two_stage",
"LTX2TwoStagePipeline",
),
"LTX2DistilledPipeline": ( "LTX2DistilledPipeline": (
"ltx2", "ltx2",
"pipeline_ltx2_two_stage", "pipeline_ltx2_two_stage",
@@ -521,7 +516,6 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
"WanPipeline": "get_wan22_post_process_func", "WanPipeline": "get_wan22_post_process_func",
"WanVACEPipeline": "get_wan22_vace_post_process_func", "WanVACEPipeline": "get_wan22_vace_post_process_func",
"LTX2Pipeline": "get_ltx2_post_process_func", "LTX2Pipeline": "get_ltx2_post_process_func",
"LTX2TwoStagePipeline": "get_ltx2_post_process_func",
"LTX2DistilledPipeline": "get_ltx2_post_process_func", "LTX2DistilledPipeline": "get_ltx2_post_process_func",
"LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func", "LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func",
"LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func", "LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func",
+3 -2
View File
@@ -790,9 +790,10 @@ class OmniServeCommand(CLISubcommand):
"--vae-parallel-mode", "--vae-parallel-mode",
type=str, type=str,
default="tile", default="tile",
choices=["tile", "spatial_shard_height", "spatial_shard_width"], choices=["tile", "auto", "spatial_shard_height", "spatial_shard_width"],
help="VAE parallel decode strategy for diffusion models. " help="VAE parallel decode strategy for diffusion models. "
"'tile' (default) uses patch/tile parallel decode; " "'tile' (default) uses patch/tile decode; 'auto' selects the longer spatial-shard axis for "
"validated Wan tiled decodes on a full DiT group, then falls back to patch/tile decode; "
"'spatial_shard_height'/'spatial_shard_width' use spatially-sharded decode that splits " "'spatial_shard_height'/'spatial_shard_width' use spatially-sharded decode that splits "
"decoder feature maps along height/width and exchanges halo regions. The " "decoder feature maps along height/width and exchanges halo regions. The "
"'spatial_shard_*' modes require vae_patch_parallel_size to match the DiT group size. " "'spatial_shard_*' modes require vae_patch_parallel_size to match the DiT group size. "
@@ -1,8 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# NOTE: Do not import model classes in this file. Importing any
# submodule in this package triggers __init__.py execution, and
# both the model registry and pipeline registry import submodules
# directly — heavy imports here would be loaded as a side effect
# even though nothing depends on these re-exports.
@@ -1,457 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 The vLLM-Omni team.
# Copyright (c) rednote-hilab. All rights reserved.
# Adapted from:
# https://github.com/rednote-hilab/dots.tts/tree/a393d2e/src/dots_tts/modules/backbone
#
# Merged from two upstream files (preserved as-is, inference behaviour
# unchanged; only internal imports were folded inline):
# - modules/backbone/layers.py -> Dropout, Mlp, rotate_half,
# apply_rotary_pos_emb, RotaryEmbedding,
# MultiHeadAttention
# (Conv1d / ConvTranspose1d are NOT
# re-vendored here — they already live in
# dots_tts_vocoder.py)
# - modules/backbone/dit.py -> modulate, TimestepEmbedder, FinalLayer,
# DiTBlock, DiT
#
# Per-section upstream attributions are preserved at each section header.
# Nothing in this file diverges from upstream behaviour.
from __future__ import annotations
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
# ============================================================================
# Building blocks (adapted from modules/backbone/layers.py)
# ----------------------------------------------------------------------------
# Conv1d / ConvTranspose1d from layers.py are already vendored in
# dots_tts_vocoder.py (they are used by the BigVGAN-style AudioVAE). The
# pieces below are the ones consumed by the DiT flow-matching head.
# ============================================================================
class Dropout(nn.Module):
def __init__(self, p: float = 0.5, inplace: bool = False, force_drop: bool = False, **_kwargs):
super().__init__()
if p < 0.0 or p > 1.0:
raise ValueError(f"dropout probability has to be between 0 and 1, but got {p}")
self.p = p
self.inplace = inplace
self.force_drop = force_drop
def forward(self, x, **_kwargs):
return F.dropout(
x,
p=self.p,
training=True if self.force_drop else self.training,
inplace=self.inplace,
)
class Mlp(nn.Module):
def __init__(
self,
hidden_size,
ffn_hidden_size=4096,
act_layer=nn.GELU,
dropout=0.0,
**_kwargs,
):
super().__init__()
self.fc1 = nn.Linear(hidden_size, ffn_hidden_size)
self.act = act_layer()
self.fc2 = nn.Linear(ffn_hidden_size, hidden_size)
self.drop = Dropout(dropout)
def forward(self, x, _mask=None):
x = self.fc1(x)
x = self.act(x)
x = self.drop(x)
x = self.fc2(x)
return self.drop(x)
def rotate_half(x):
x1, x2 = x.chunk(2, dim=-1)
return torch.cat((-x2, x1), dim=-1)
@torch.autocast(enabled=False, device_type="cuda")
def apply_rotary_pos_emb(pos, t):
if pos.dim() == 3:
pos = pos.unsqueeze(1)
return t * pos.cos() + rotate_half(t) * pos.sin()
class RotaryEmbedding(nn.Module):
def __init__(self, dim, theta=50000):
super().__init__()
self.register_buffer(
"inv_freq",
1.0 / (theta ** (torch.arange(0, dim, 2).float() / dim)),
persistent=False,
)
self._theta = float(theta)
def _apply(self, fn):
inv_freq = self.inv_freq
super()._apply(fn)
self.inv_freq = inv_freq.to(device=self.inv_freq.device, dtype=torch.float32)
return self
@torch.autocast(enabled=False, device_type="cuda")
def forward(self, t):
inv_freq = self.inv_freq
if inv_freq.device != t.device:
raise RuntimeError(f"RotaryEmbedding buffer device mismatch: inv_freq={inv_freq.device} input={t.device}.")
t = t.to(dtype=inv_freq.dtype)
if t.dim() == 1:
freqs = torch.einsum("i , j -> i j", t, inv_freq)
else:
freqs = torch.einsum("bi, j -> bij", t, inv_freq)
return torch.cat((freqs, freqs), dim=-1)
class MultiHeadAttention(nn.Module):
"""Multi-head attention"""
def __init__(
self,
hidden_size: int,
num_heads: int = 8,
qkv_bias: bool = False,
qk_norm: bool = False,
attn_drop: float = 0.0,
dropout: float = 0.0,
norm_layer: str = "LayerNorm",
rotary_bias: bool = False,
rotary_theta: float | None = 50000,
**_kwargs,
):
super().__init__()
assert hidden_size % num_heads == 0, "hidden_size should be divisible by num_heads"
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads
self.scale = self.head_dim**-0.5
self.rotary_bias = rotary_bias
self.q_proj = nn.Linear(hidden_size, hidden_size, bias=qkv_bias)
self.k_proj = nn.Linear(hidden_size, hidden_size, bias=qkv_bias)
self.v_proj = nn.Linear(hidden_size, hidden_size, bias=qkv_bias)
norm_layer = getattr(nn, norm_layer)
self.q_norm = norm_layer(self.head_dim) if qk_norm else nn.Identity()
self.k_norm = norm_layer(self.head_dim) if qk_norm else nn.Identity()
self.attn_drop = Dropout(attn_drop)
self.o_proj = nn.Linear(hidden_size, hidden_size)
self.o_dropout = Dropout(dropout)
if self.rotary_bias:
self.rotary = RotaryEmbedding(self.head_dim, theta=rotary_theta)
def forward(self, q, k=None, v=None, mask=None, pos_ids=None, **_kwargs):
k = k or q
v = v or q
B, L, _ = q.shape
_, S, _ = v.shape
if mask is not None:
if mask.ndim == 2: # [B, L]
assert L == S
mask = rearrange(mask, "b j -> b 1 1 j")
mask = mask.expand(-1, self.num_heads, L, -1)
elif mask.ndim == 3: # [B, L, S]
assert mask.size(1) == L and mask.size(2) == S
mask = mask.unsqueeze(1).expand(-1, self.num_heads, -1, -1)
q, k, v = self.q_proj(q), self.k_proj(k), self.v_proj(v)
q = rearrange(q, "b n (h d) -> b h n d", h=self.num_heads)
k = rearrange(k, "b n (h d) -> b h n d", h=self.num_heads)
v = rearrange(v, "b n (h d) -> b h n d", h=self.num_heads)
q, k = self.q_norm(q), self.k_norm(k)
# Apply rotary
if self.rotary_bias:
if L == S:
if pos_ids is None:
rotary_emb = self.rotary(torch.arange(L, device=q.device))
else:
rotary_emb = self.rotary(pos_ids)
q, k = (apply_rotary_pos_emb(rotary_emb, tensor) for tensor in (q, k))
else:
q_rotary_emb = self.rotary(torch.arange(L, device=q.device))
k_rotary_emb = self.rotary(torch.arange(S, device=k.device))
q = apply_rotary_pos_emb(q_rotary_emb, q)
k = apply_rotary_pos_emb(k_rotary_emb, k)
attn_bias = torch.zeros(B, self.num_heads, L, S, dtype=q.dtype, device=q.device)
if mask is not None:
attn_bias.masked_fill_(mask.logical_not(), float("-inf"))
out = F.scaled_dot_product_attention(
q,
k,
v,
attn_mask=attn_bias,
dropout_p=self.attn_drop.p if self.training else 0.0,
)
out = rearrange(out, "b h n d -> b n (h d)")
return self.o_dropout(self.o_proj(out))
def decode_step(self, x, *, cache, positions: torch.Tensor):
if x.size(1) <= 0:
raise ValueError("MultiHeadAttention.decode_step expects a non-empty input.")
if positions.ndim != 1 or positions.size(0) != x.size(1):
raise ValueError("MultiHeadAttention.decode_step positions must match the decode block length.")
q = self.q_proj(x)
k = self.k_proj(x)
v = self.v_proj(x)
q = rearrange(q, "b n (h d) -> b h n d", h=self.num_heads)
k = rearrange(k, "b n (h d) -> b h n d", h=self.num_heads)
v = rearrange(v, "b n (h d) -> b h n d", h=self.num_heads)
q, k = self.q_norm(q), self.k_norm(k)
block_len = q.size(2)
if self.rotary_bias:
rotary_emb = self.rotary(positions)
q = apply_rotary_pos_emb(rotary_emb, q)
k = apply_rotary_pos_emb(rotary_emb, k)
cached_k, cached_v = cache
cached_k.index_copy_(2, positions, k)
cached_v.index_copy_(2, positions, v)
cache_capacity = cached_k.size(2)
key_positions = torch.arange(
cache_capacity,
device=x.device,
dtype=torch.long,
).unsqueeze(0)
query_positions = positions.unsqueeze(1)
causal_mask = key_positions <= query_positions
valid_mask = key_positions <= positions[-1]
attn_bias = torch.zeros(
q.size(0),
self.num_heads,
block_len,
cache_capacity,
dtype=q.dtype,
device=q.device,
)
attn_bias.masked_fill_(
(causal_mask & valid_mask).unsqueeze(0).unsqueeze(0).logical_not(),
float("-inf"),
)
out = F.scaled_dot_product_attention(
q,
cached_k,
cached_v,
attn_mask=attn_bias,
dropout_p=self.attn_drop.p if self.training else 0.0,
)
out = rearrange(out, "b h n d -> b n (h d)")
return self.o_dropout(self.o_proj(out)), cache
# ============================================================================
# DiT flow-matching head (adapted from modules/backbone/dit.py)
# ----------------------------------------------------------------------------
# DiT.__init__ takes a ``transformer_config`` object exposing ``.to_dict()``,
# ``.hidden_size`` and ``.num_layers``. Upstream constructs it from a pydantic
# config class higher up the stack; vLLM-Omni callers can pass anything that
# satisfies the same minimal interface (e.g. a stdlib @dataclass with a
# ``to_dict`` method or SimpleNamespace). See dots_tts_talker.py for the
# concrete soar-checkpoint config factory.
# ============================================================================
def modulate(x, shift, scale, **_kwargs):
return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
class TimestepEmbedder(nn.Module):
def __init__(self, hidden_size, frequency_embedding_size=256):
super().__init__()
self.mlp = nn.Sequential(
nn.Linear(frequency_embedding_size, hidden_size, bias=True),
nn.SiLU(),
nn.Linear(hidden_size, hidden_size, bias=True),
)
self.frequency_embedding_size = frequency_embedding_size
@staticmethod
def timestep_embedding(t, dim, max_period=10000):
half = dim // 2
freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half).to(
device=t.device
)
args = t[:, None].float() * freqs[None]
embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)
if dim % 2:
embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1)
return embedding
def forward(self, t):
t_freq = self.timestep_embedding(t, self.frequency_embedding_size)
return self.mlp(t_freq)
class FinalLayer(nn.Module):
def __init__(self, hidden_size, output_size):
super().__init__()
self.adaLN_modulation = nn.Sequential(
nn.SiLU(),
nn.Linear(hidden_size, 2 * hidden_size, bias=True),
)
self.norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-5)
self.linear = nn.Linear(hidden_size, output_size, bias=True)
def forward(self, x, c, **_kwargs):
shift, scale = self.adaLN_modulation(c).chunk(2, dim=1)
x = modulate(self.norm(x), shift, scale)
return self.linear(x)
class DiTBlock(nn.Module):
def __init__(
self,
attention: nn.Module,
ffn: nn.Module,
hidden_size: int = 1024,
modulation: bool = False,
eps: float = 1e-5,
**_kwargs,
):
super().__init__()
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=not modulation, eps=eps)
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=not modulation, eps=eps)
self.attn = attention
self.ffn = ffn
self.modulation = modulation
if modulation:
self.adaLN_modulation = nn.Sequential(
nn.SiLU(),
nn.Linear(hidden_size, 6 * hidden_size, bias=True),
)
def forward(self, x, condition=None, mask=None, **kwargs):
if condition is None:
assert not self.modulation, "Without global condition, must set modulation to False"
else:
assert self.modulation, "With global condition, must set modulation to True"
shift_attn, scale_attn, gate_attn, shift_ffn, scale_ffn, gate_ffn = self.adaLN_modulation(condition).chunk(
6, dim=1
)
if condition is not None:
pack_indices = kwargs.get("pack_indices")
if pack_indices is not None:
gate_attn = gate_attn[pack_indices]
gate_ffn = gate_ffn[pack_indices]
else:
gate_attn = gate_attn.unsqueeze(1)
gate_ffn = gate_ffn.unsqueeze(1)
if condition is not None:
x = x + gate_attn * self.attn(
modulate(self.norm1(x), shift_attn, scale_attn, **kwargs),
mask=mask,
**kwargs,
)
else:
x = x + self.attn(self.norm1(x), mask=mask, **kwargs)
if condition is not None:
x = x + gate_ffn * self.ffn(modulate(self.norm2(x), shift_ffn, scale_ffn, **kwargs))
else:
x = x + self.ffn(self.norm2(x), mask=mask)
return x
class DiT(nn.Module):
def __init__(
self,
in_dim,
out_dim,
transformer_config,
*,
mode: str = "flow_matching",
):
super().__init__()
if mode not in {"flow_matching", "meanflow"}:
raise ValueError(f"DiT mode must be 'flow_matching' or 'meanflow', got {mode!r}.")
transformer_kwargs = transformer_config.to_dict()
model_dim = transformer_config.hidden_size
self.mode = mode
self.num_layers = transformer_config.num_layers
self.input_layer = nn.Linear(in_dim, model_dim)
self.time_embedder = TimestepEmbedder(model_dim)
if mode == "meanflow":
self.duration_embedder = TimestepEmbedder(model_dim)
self.blocks = nn.ModuleList()
for i in range(self.num_layers):
attn_block = MultiHeadAttention(**transformer_kwargs, name=f"layer_{i}")
ffn_block = Mlp(act_layer=lambda: nn.GELU(approximate="tanh"), **transformer_kwargs)
self.blocks.append(DiTBlock(attention=attn_block, ffn=ffn_block, **transformer_kwargs))
self.output_layer = FinalLayer(model_dim, out_dim)
self.initialize_weights()
def initialize_weights(self):
def _basic_init(module):
if isinstance(module, nn.Linear):
torch.nn.init.xavier_uniform_(module.weight)
if module.bias is not None:
nn.init.constant_(module.bias, 0)
self.apply(_basic_init)
nn.init.normal_(self.time_embedder.mlp[0].weight, std=0.02)
nn.init.normal_(self.time_embedder.mlp[2].weight, std=0.02)
for block in self.blocks:
if hasattr(block, "adaLN_modulation"):
nn.init.constant_(block.adaLN_modulation[-1].weight, 0)
nn.init.constant_(block.adaLN_modulation[-1].bias, 0)
nn.init.constant_(self.output_layer.adaLN_modulation[-1].weight, 0)
nn.init.constant_(self.output_layer.adaLN_modulation[-1].bias, 0)
nn.init.constant_(self.output_layer.linear.weight, 0)
nn.init.constant_(self.output_layer.linear.bias, 0)
def forward(
self,
x,
timesteps,
duration: torch.Tensor | None = None,
mask=None,
attn_mask=None,
g_cond: torch.Tensor | None = None,
**kwargs,
):
t = self.time_embedder(timesteps)
c = t
duration_embedder = getattr(self, "duration_embedder", None)
if duration_embedder is not None and duration is not None:
c = c + duration_embedder(duration)
if g_cond is not None:
c = c + g_cond
x = self.input_layer(x)
for block in self.blocks:
x = block(x, c, mask=attn_mask, **kwargs)
return self.output_layer(x, c, **kwargs)
@@ -1,355 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 The vLLM-Omni team.
# Copyright (c) rednote-hilab. All rights reserved.
# Adapted from:
# https://github.com/rednote-hilab/dots.tts/tree/a393d2e/src/dots_tts/modules/backbone
#
# Single upstream source (preserved as-is, inference behaviour unchanged;
# only the internal `from dots_tts.modules.backbone.layers import ...` line
# was folded to point at our sibling vendor files):
# - modules/backbone/semantic_encoder.py -> SemanticEncoderDecodeState,
# TransformerEncoderLayer,
# SuperviseEncoder,
# VAESemanticEncoder
#
# Building blocks (Conv1d / Mlp / MultiHeadAttention) are imported from the
# sibling vendor files where they already live:
# - Conv1d <- dots_tts_vocoder.py
# - Mlp <- dots_tts_dit.py
# - MultiHeadAttention <- dots_tts_dit.py
#
# Nothing else in this file diverges from upstream behaviour.
from __future__ import annotations
from dataclasses import dataclass
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from vllm_omni.model_executor.models.dots_tts.dots_tts_dit import (
Mlp,
MultiHeadAttention,
)
from vllm_omni.model_executor.models.dots_tts.dots_tts_vocoder import Conv1d
@dataclass
class SemanticEncoderDecodeState:
conv_tail: torch.Tensor
layer_caches: tuple[tuple[torch.Tensor, torch.Tensor], ...]
seq_len: int
class TransformerEncoderLayer(nn.Module):
def __init__(
self,
hidden_size,
num_heads=16,
ffn_hidden_size=4096,
attn_dropout=0.0,
ffn_dropout=0.0,
norm_layer="LayerNorm",
**kwargs,
):
super().__init__()
self.attn = MultiHeadAttention(
hidden_size,
num_heads,
attn_drop=attn_dropout,
norm_layer=norm_layer,
**kwargs,
)
norm_cls = getattr(nn, norm_layer)
self.attn_norm = norm_cls(hidden_size)
self.ffn = Mlp(hidden_size, ffn_hidden_size, dropout=ffn_dropout, act_layer=nn.SiLU)
self.ffn_norm = norm_cls(hidden_size)
self.hidden_size = hidden_size
def _build_causal_mask(self, T: int, device): # noqa: N803
return torch.tril(torch.ones(T, T, dtype=torch.bool, device=device))
def _build_padding_mask(self, x_lens, max_len: int, device):
B = x_lens.size(0)
positions = torch.arange(max_len, device=device).unsqueeze(0).expand(B, -1)
return positions < x_lens.unsqueeze(1)
def _fuse_attn_mask(self, causal_mask, padding_mask):
if causal_mask is None and padding_mask is None:
return None
if causal_mask is None:
row = padding_mask.unsqueeze(2)
col = padding_mask.unsqueeze(1)
return row & col
if padding_mask is None:
return causal_mask.unsqueeze(0)
_B, _T = padding_mask.shape
causal = causal_mask.unsqueeze(0)
row = padding_mask.unsqueeze(2)
col = padding_mask.unsqueeze(1)
pad_2d = row & col
return causal & pad_2d
def forward(
self,
x,
x_lens=None,
causal=True,
):
_B, T, C = x.shape
assert self.hidden_size == C
device = x.device
causal_mask = self._build_causal_mask(T, device) if causal else None
if x_lens is not None:
padding_mask = self._build_padding_mask(x_lens, T, device)
else:
padding_mask = None
fused_mask = self._fuse_attn_mask(causal_mask, padding_mask)
h = self.attn_norm(x)
h = self.attn(
q=h,
mask=fused_mask,
)
x = x + h
h = self.ffn_norm(x)
h = self.ffn(h)
return x + h
def decode_step(
self,
x,
*,
cache: tuple[torch.Tensor, torch.Tensor],
positions: torch.Tensor,
):
if x.size(1) <= 0:
raise ValueError("TransformerEncoderLayer.decode_step expects a non-empty input.")
h = self.attn_norm(x)
h, cache = self.attn.decode_step(h, cache=cache, positions=positions)
x = x + h
h = self.ffn_norm(x)
h = self.ffn(h)
return x + h, cache
class SuperviseEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.hidden_size = config.get("hidden_size", 1024)
self.layers = nn.ModuleList(
[
TransformerEncoderLayer(
hidden_size=self.hidden_size,
num_heads=config.get("num_heads", 16),
ffn_hidden_size=config.get("ffn_hidden_size", 4096),
norm_layer=config.get("norm_layer", "LayerNorm"),
)
for _ in range(config.get("num_layers", 6))
]
)
self.causal = config.get("causal", False)
def forward(self, x, x_lens=None):
batch_size, seq_len, _ = x.shape
if x_lens is None:
x_lens = torch.full((batch_size,), seq_len, device=x.device, dtype=torch.long)
for layer in self.layers:
x = layer(x, x_lens=x_lens, causal=self.causal)
return x
def init_decode_state(
self,
*,
batch_size: int,
max_seq_len: int,
device: torch.device,
dtype: torch.dtype,
):
layer_caches = []
for layer in self.layers:
cache_shape = (
batch_size,
layer.attn.num_heads,
max_seq_len,
layer.attn.head_dim,
)
layer_caches.append(
(
torch.zeros(cache_shape, dtype=dtype, device=device),
torch.zeros(cache_shape, dtype=dtype, device=device),
)
)
return tuple(layer_caches)
def reset_decode_state(
self,
layer_caches: tuple[tuple[torch.Tensor, torch.Tensor], ...],
) -> None:
if len(layer_caches) != len(self.layers):
raise ValueError("Layer cache count does not match encoder depth.")
for key_cache, value_cache in layer_caches:
key_cache.zero_()
value_cache.zero_()
def decode_step(self, x, *, layer_caches, positions: torch.Tensor):
if len(layer_caches) != len(self.layers):
raise ValueError("Layer cache count does not match encoder depth.")
for layer, cache in zip(self.layers, layer_caches, strict=True):
x, _ = layer.decode_step(x, cache=cache, positions=positions)
return x
class VAESemanticEncoder(nn.Module):
def __init__(self, in_dim, out_dim, config):
super().__init__()
in_ds_rate = 2
self.patch_size = int(config.patch_size)
self.in_ds_rate = in_ds_rate
self.ds_proj = Conv1d(in_dim, in_dim, kernel_size=in_ds_rate, stride=in_ds_rate, causal=True)
self.in_proj = nn.Linear(in_dim, config.PatchEncoder.hidden_size)
self.encoder = SuperviseEncoder(config.PatchEncoder)
self.out_ds_rate = self.patch_size // in_ds_rate
self.out_proj = nn.Linear(config.PatchEncoder.hidden_size * self.out_ds_rate, out_dim)
def forward(self, x, x_lens=None):
x = self._downsample(x)
x = self.in_proj(x)
z = self.encoder(x, x_lens=x_lens)
return self._project_embeddings(z)
def init_decode_state(
self,
*,
max_audio_patch_count: int,
batch_size: int,
device: torch.device,
dtype: torch.dtype,
) -> SemanticEncoderDecodeState:
return SemanticEncoderDecodeState(
conv_tail=torch.zeros(
(batch_size, self.ds_proj.in_channels, self.ds_proj.left_padding),
dtype=dtype,
device=device,
),
layer_caches=self.encoder.init_decode_state(
batch_size=batch_size,
max_seq_len=max_audio_patch_count * self.out_ds_rate,
device=device,
dtype=dtype,
),
seq_len=0,
)
def reset_decode_state(self, state: SemanticEncoderDecodeState) -> None:
state.conv_tail.zero_()
self.encoder.reset_decode_state(state.layer_caches)
state.seq_len = 0
def prefill(
self,
x,
state: SemanticEncoderDecodeState,
) -> tuple[torch.Tensor, SemanticEncoderDecodeState]:
if x.ndim != 3:
raise ValueError(f"VAESemanticEncoder.prefill expects rank-3 input, got {tuple(x.shape)}.")
if x.size(1) % self.patch_size != 0:
raise ValueError(f"Prompt latent length {x.size(1)} must be divisible by patch_size={self.patch_size}.")
if x.size(1) == 0:
return (
x.new_zeros((x.size(0), 0, self.out_proj.out_features)),
state,
)
if state.conv_tail.size(0) != x.size(0):
raise ValueError("VAESemanticEncoder.prefill batch size does not match decode state.")
step_inputs = self.in_proj(self._downsample(x))
expected_token_count = (x.size(1) // self.patch_size) * self.out_ds_rate
if step_inputs.size(1) != expected_token_count:
raise RuntimeError(
"Patch encoder prefill produced an unexpected token count: "
f"expected={expected_token_count} actual={step_inputs.size(1)}."
)
current_seq_len = state.seq_len
next_seq_len = current_seq_len + step_inputs.size(1)
cache_capacity = state.layer_caches[0][0].size(2)
if next_seq_len > cache_capacity:
raise ValueError(
"Patch encoder prefill exceeds decode-state capacity: "
f"required={next_seq_len} capacity={cache_capacity}."
)
positions = torch.arange(step_inputs.size(1), device=x.device, dtype=torch.long) + current_seq_len
encoded = self.encoder.decode_step(
step_inputs,
layer_caches=state.layer_caches,
positions=positions,
)
embedding = self._project_embeddings(encoded)
raw = x.transpose(1, 2)
state.conv_tail.copy_(raw[..., -self.ds_proj.left_padding :])
state.seq_len = next_seq_len
return embedding, state
def decode_patch(
self,
latent_patch,
conv_tail: torch.Tensor,
layer_caches: tuple[tuple[torch.Tensor, torch.Tensor], ...],
positions: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
if latent_patch.ndim != 3:
raise ValueError(f"VAESemanticEncoder.decode_patch expects rank-3 input, got {tuple(latent_patch.shape)}.")
if latent_patch.size(1) != self.patch_size:
raise ValueError(f"decode_patch expects patch length {self.patch_size}, got {latent_patch.size(1)}.")
if positions.ndim != 1 or positions.size(0) != self.out_ds_rate:
raise ValueError("decode_patch positions must be a rank-1 tensor matching out_ds_rate.")
step_inputs, conv_tail = self._downsample_step(
latent_patch,
conv_tail=conv_tail,
)
if step_inputs.size(1) != self.out_ds_rate:
raise RuntimeError(f"Downsample step produced {step_inputs.size(1)} tokens, expected {self.out_ds_rate}.")
encoded = self.encoder.decode_step(
step_inputs,
layer_caches=layer_caches,
positions=positions,
)
embedding = self._project_embeddings(encoded)
return embedding, conv_tail
def _downsample(self, x):
return self.ds_proj(x.transpose(1, 2)).transpose(1, 2)
def _project_embeddings(self, z):
if self.out_ds_rate > 1:
z = rearrange(z, "b (s d) h -> b s (d h)", d=self.out_ds_rate)
return self.out_proj(z)
def _downsample_step(self, latent_patch, *, conv_tail):
raw = latent_patch.transpose(1, 2)
conv_input = torch.cat([conv_tail, raw], dim=-1)
projected = F.conv1d(
conv_input,
self.ds_proj.weight,
self.ds_proj.bias,
stride=self.ds_proj.stride[0],
padding=0,
dilation=self.ds_proj.dilation[0],
groups=self.ds_proj.groups,
).transpose(1, 2)
new_conv_tail = raw[..., -self.ds_proj.left_padding :]
return self.in_proj(projected), new_conv_tail
@@ -1,70 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""dots.tts prompt builder for vllm-omni's Omni engine + serving layer.
Mirrors upstream ``build_generation_schedule`` (rednote-hilab/dots.tts @
a393d2e data/pipelines/tokenizing.py:169) for the
no-prompt-audio + audio-placeholder path — text-only zero-shot TTS.
V1 returns real Qwen2 token IDs (not placeholders ``[1] * N`` like
voxcpm2's builder). Reason: our talker's preprocess() runs
``self.model.embed_tokens(input_ids)`` directly, so real IDs flow
through naturally. voxcpm2 uses placeholders because its prefill mixes
reference-audio embeddings (no token IDs); dots.tts V1 zero-shot is
purely text + audio_gen_start, all real IDs.
Voice clone (reference audio + speaker x-vector) is intentionally not
wired here — V1 is zero-shot only. Add reference_audio /
voice_profile / prompt_text branches in a follow-up (mirror voxcpm2
``build_voxcpm2_prompt``).
"""
from __future__ import annotations
from typing import Any
from vllm.inputs import tokens_input
# Upstream rednote-hilab/dots.tts @ a393d2e
# data/pipelines/tts_pipeline.py:17-18
_TTS_TEXT_PREFIX = "[文本]"
_TTS_AUDIO_PREFIX = "[文本对应语音]"
_AUDIO_GEN_START_TOKEN = "<|audio_gen_start|>"
def build_dots_tts_prompt(
tokenizer: Any,
text: str,
) -> dict[str, Any]:
"""Build a dots.tts prefill prompt dict for ``Omni.generate()``.
Returns ``{"prompt_token_ids": [...], "additional_information": {...}}``
where ``prompt_token_ids`` is the real Qwen2 token sequence:
[text_tokens of "[文本]<text>[文本对应语音]", audio_gen_start_id]
talker's preprocess() embeds these into ``hidden_states`` for the AR
base LM. Decode then drives via ``state.curr_embed_for_next`` (DiT
→ patch_encoder AR loopback).
"""
if not isinstance(text, str) or not text.strip():
raise ValueError(f"text must be a non-empty string, got {text!r}")
prompt_text = f"{_TTS_TEXT_PREFIX}{text}{_TTS_AUDIO_PREFIX}"
text_ids = tokenizer.encode(prompt_text, add_special_tokens=False)
audio_gen_start_id = tokenizer.convert_tokens_to_ids(_AUDIO_GEN_START_TOKEN)
if not isinstance(audio_gen_start_id, int) or audio_gen_start_id == tokenizer.unk_token_id:
raise ValueError(
f"Tokenizer does not know {_AUDIO_GEN_START_TOKEN!r} "
f"(got id={audio_gen_start_id!r}). Did you load the "
f"dots.tts tokenizer (with added_tokens.json)?"
)
prompt_token_ids = list(text_ids) + [audio_gen_start_id]
prompt = tokens_input(prompt_token_ids=prompt_token_ids)
prompt["additional_information"] = {
# Reserved for future voice clone (ref_audio / speaker x-vector).
# V1 zero-shot leaves this empty; preprocess() ignores it.
}
return prompt
@@ -1,732 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 The vLLM-Omni team.
# Copyright (c) rednote-hilab. All rights reserved.
# Portions Copyright 3D-Speaker
# (https://github.com/alibaba-damo-academy/3D-Speaker), Apache-2.0.
# Adapted from:
# https://github.com/rednote-hilab/dots.tts/tree/a393d2e/src/dots_tts/modules/speaker
# (whose CAM++ implementation is itself adapted from 3D-Speaker).
#
# Merged from four upstream files (preserved as-is, inference behaviour
# unchanged; only internal cross-module imports were folded inline):
# - modules/speaker/encoder.py -> SpeakerXVectorFeatures
# - modules/speaker/fbank.py -> fbank constants + extract_speaker_fbank
# - modules/speaker/campplus.py -> FCM, CAMPPlus
# - modules/speaker/campplus_layers.py -> get_nonlinear, statistics_pooling,
# StatsPool, TDNNLayer, CAMLayer,
# CAMDenseTDNNLayer, CAMDenseTDNNBlock,
# TransitLayer, DenseLayer, BasicResBlock
#
# Plus two tiny helpers folded from utils/audio.py (used by
# extract_speaker_fbank):
# - high_quality_resample, extract_fbank
#
# Nothing in this file diverges from upstream behaviour.
from __future__ import annotations
import math
import random
from collections import OrderedDict
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.checkpoint as cp
import torchaudio
import torchaudio.compliance.kaldi as Kaldi
import torchaudio.functional as AF
from torch.nn.utils.rnn import pad_sequence
# ============================================================================
# Constants (from modules/speaker/fbank.py)
# ============================================================================
_SPEAKER_FBANK_SAMPLE_RATE = 16000
_SPEAKER_FBANK_N_MELS = 80
_SPEAKER_FBANK_MEAN_NORM = True
_SPEAKER_FBANK_DITHER = 0.0
# ============================================================================
# Audio helpers (folded from utils/audio.py — used by extract_speaker_fbank)
# ============================================================================
def high_quality_resample(x, orig_sr, target_sr):
return AF.resample(
x,
orig_freq=orig_sr,
new_freq=target_sr,
lowpass_filter_width=64,
rolloff=0.95,
resampling_method="sinc_interp_kaiser",
)
def extract_fbank(
waveform: torch.Tensor,
*,
sample_rate: int,
n_mels: int,
dither: float = 0.0,
mean_norm: bool = False,
) -> torch.Tensor:
if waveform.ndim == 1:
feature_input = waveform.unsqueeze(0)
elif waveform.ndim == 2:
feature_input = waveform if waveform.size(0) == 1 else waveform[0:1, :]
else:
raise ValueError(f"FBank expects a 1D or 2D waveform, got shape {tuple(waveform.shape)}.")
features = Kaldi.fbank(
feature_input,
num_mel_bins=n_mels,
sample_frequency=sample_rate,
dither=dither,
)
if mean_norm:
features = features - features.mean(dim=0, keepdim=True)
return features
# ============================================================================
# Speaker fbank entry (from modules/speaker/fbank.py)
# ============================================================================
def extract_speaker_fbank(
waveform: torch.Tensor,
*,
sample_rate: int,
) -> torch.Tensor:
feature_input = waveform
if sample_rate != _SPEAKER_FBANK_SAMPLE_RATE:
feature_input = high_quality_resample(
waveform,
orig_sr=sample_rate,
target_sr=_SPEAKER_FBANK_SAMPLE_RATE,
)
return extract_fbank(
feature_input,
sample_rate=_SPEAKER_FBANK_SAMPLE_RATE,
n_mels=_SPEAKER_FBANK_N_MELS,
dither=_SPEAKER_FBANK_DITHER,
mean_norm=_SPEAKER_FBANK_MEAN_NORM,
)
# ============================================================================
# CAM++ building blocks (from modules/speaker/campplus_layers.py)
# ----------------------------------------------------------------------------
# 3D-Speaker, Apache-2.0.
# ============================================================================
def get_nonlinear(config_str, channels):
nonlinear = nn.Sequential()
for name in config_str.split("-"):
if name == "relu":
nonlinear.add_module("relu", nn.ReLU(inplace=True))
elif name == "prelu":
nonlinear.add_module("prelu", nn.PReLU(channels))
elif name == "batchnorm":
nonlinear.add_module("batchnorm", nn.BatchNorm1d(channels))
elif name == "batchnorm_":
nonlinear.add_module("batchnorm", nn.BatchNorm1d(channels, affine=False))
else:
raise ValueError(f"Unexpected module ({name}).")
return nonlinear
def statistics_pooling(x, dim=-1, keepdim=False, unbiased=True, _eps=1e-2):
mean = x.mean(dim=dim)
std = x.std(dim=dim, unbiased=unbiased)
stats = torch.cat([mean, std], dim=-1)
if keepdim:
stats = stats.unsqueeze(dim=dim)
return stats
class StatsPool(nn.Module):
def forward(self, x):
return statistics_pooling(x)
class TDNNLayer(nn.Module):
def __init__(
self,
in_channels,
out_channels,
kernel_size,
stride=1,
padding=0,
dilation=1,
bias=False,
config_str="batchnorm-relu",
):
super().__init__()
if padding < 0:
assert kernel_size % 2 == 1, f"Expect equal paddings, but got even kernel size ({kernel_size})"
padding = (kernel_size - 1) // 2 * dilation
self.linear = nn.Conv1d(
in_channels,
out_channels,
kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=bias,
)
self.nonlinear = get_nonlinear(config_str, out_channels)
def forward(self, x):
x = self.linear(x)
return self.nonlinear(x)
class CAMLayer(nn.Module):
def __init__(
self,
bn_channels,
out_channels,
kernel_size,
stride,
padding,
dilation,
bias,
reduction=2,
):
super().__init__()
self.linear_local = nn.Conv1d(
bn_channels,
out_channels,
kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=bias,
)
self.linear1 = nn.Conv1d(bn_channels, bn_channels // reduction, 1)
self.relu = nn.ReLU(inplace=True)
self.linear2 = nn.Conv1d(bn_channels // reduction, out_channels, 1)
self.sigmoid = nn.Sigmoid()
def forward(self, x):
y = self.linear_local(x)
context = x.mean(-1, keepdim=True) + self.seg_pooling(x)
context = self.relu(self.linear1(context))
m = self.sigmoid(self.linear2(context))
return y * m
def seg_pooling(self, x, seg_len=100, stype="avg"):
if stype == "avg":
seg = F.avg_pool1d(x, kernel_size=seg_len, stride=seg_len, ceil_mode=True)
elif stype == "max":
seg = F.max_pool1d(x, kernel_size=seg_len, stride=seg_len, ceil_mode=True)
else:
raise ValueError("Wrong segment pooling type.")
shape = seg.shape
seg = seg.unsqueeze(-1).expand(*shape, seg_len).reshape(*shape[:-1], -1)
return seg[..., : x.shape[-1]]
class CAMDenseTDNNLayer(nn.Module):
def __init__(
self,
in_channels,
out_channels,
bn_channels,
kernel_size,
stride=1,
dilation=1,
bias=False,
config_str="batchnorm-relu",
memory_efficient=False,
):
super().__init__()
assert kernel_size % 2 == 1, f"Expect equal paddings, but got even kernel size ({kernel_size})"
padding = (kernel_size - 1) // 2 * dilation
self.memory_efficient = memory_efficient
self.nonlinear1 = get_nonlinear(config_str, in_channels)
self.linear1 = nn.Conv1d(in_channels, bn_channels, 1, bias=False)
self.nonlinear2 = get_nonlinear(config_str, bn_channels)
self.cam_layer = CAMLayer(
bn_channels,
out_channels,
kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=bias,
)
def bn_function(self, x):
return self.linear1(self.nonlinear1(x))
def forward(self, x):
if self.training and self.memory_efficient:
x = cp.checkpoint(self.bn_function, x)
else:
x = self.bn_function(x)
return self.cam_layer(self.nonlinear2(x))
class CAMDenseTDNNBlock(nn.ModuleList):
def __init__(
self,
num_layers,
in_channels,
out_channels,
bn_channels,
kernel_size,
stride=1,
dilation=1,
bias=False,
config_str="batchnorm-relu",
memory_efficient=False,
):
super().__init__()
for i in range(num_layers):
layer = CAMDenseTDNNLayer(
in_channels=in_channels + i * out_channels,
out_channels=out_channels,
bn_channels=bn_channels,
kernel_size=kernel_size,
stride=stride,
dilation=dilation,
bias=bias,
config_str=config_str,
memory_efficient=memory_efficient,
)
self.add_module(f"tdnnd{i + 1}", layer)
def forward(self, x):
for layer in self:
x = torch.cat([x, layer(x)], dim=1)
return x
class TransitLayer(nn.Module):
def __init__(self, in_channels, out_channels, bias=True, config_str="batchnorm-relu"):
super().__init__()
self.nonlinear = get_nonlinear(config_str, in_channels)
self.linear = nn.Conv1d(in_channels, out_channels, 1, bias=bias)
def forward(self, x):
x = self.nonlinear(x)
return self.linear(x)
class DenseLayer(nn.Module):
def __init__(self, in_channels, out_channels, bias=False, config_str="batchnorm-relu"):
super().__init__()
self.linear = nn.Conv1d(in_channels, out_channels, 1, bias=bias)
self.nonlinear = get_nonlinear(config_str, out_channels)
def forward(self, x):
if len(x.shape) == 2:
x = self.linear(x.unsqueeze(dim=-1)).squeeze(dim=-1)
else:
x = self.linear(x)
return self.nonlinear(x)
class BasicResBlock(nn.Module):
expansion = 1
def __init__(self, in_planes, planes, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=(stride, 1), padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.shortcut = nn.Sequential()
if stride != 1 or in_planes != self.expansion * planes:
self.shortcut = nn.Sequential(
nn.Conv2d(
in_planes,
self.expansion * planes,
kernel_size=1,
stride=(stride, 1),
bias=False,
),
nn.BatchNorm2d(self.expansion * planes),
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x)
return F.relu(out)
# ============================================================================
# CAM++ model (from modules/speaker/campplus.py)
# ----------------------------------------------------------------------------
# 3D-Speaker, Apache-2.0.
# ============================================================================
class FCM(nn.Module):
def __init__(
self,
block=BasicResBlock,
num_blocks=(2, 2),
m_channels=32,
feat_dim=_SPEAKER_FBANK_N_MELS,
):
super().__init__()
self.in_planes = m_channels
self.conv1 = nn.Conv2d(1, m_channels, kernel_size=3, stride=1, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(m_channels)
self.layer1 = self._make_layer(block, m_channels, num_blocks[0], stride=2)
self.layer2 = self._make_layer(block, m_channels, num_blocks[1], stride=2)
self.conv2 = nn.Conv2d(m_channels, m_channels, kernel_size=3, stride=(2, 1), padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(m_channels)
self.out_channels = m_channels * (feat_dim // 8)
def _make_layer(self, block, planes, num_blocks, stride):
strides = [stride] + [1] * (num_blocks - 1)
layers = []
for stride in strides:
layers.append(block(self.in_planes, planes, stride))
self.in_planes = planes * block.expansion
return nn.Sequential(*layers)
def forward(self, x):
x = x.unsqueeze(1)
out = F.relu(self.bn1(self.conv1(x)))
out = self.layer1(out)
out = self.layer2(out)
out = F.relu(self.bn2(self.conv2(out)))
shape = out.shape
return out.reshape(shape[0], shape[1] * shape[2], shape[3])
class CAMPPlus(nn.Module):
_TDNN_KERNEL_SIZE = 5
_TDNN_STRIDE = 2
_TDNN_PADDING = 2
def __init__(
self,
feat_dim=_SPEAKER_FBANK_N_MELS,
embedding_size=512,
growth_rate=32,
bn_size=4,
init_channels=128,
config_str="batchnorm-relu",
memory_efficient=True,
):
super().__init__()
self.head = FCM(feat_dim=feat_dim)
channels = self.head.out_channels
self.xvector = nn.Sequential(
OrderedDict(
[
(
"tdnn",
TDNNLayer(
channels,
init_channels,
self._TDNN_KERNEL_SIZE,
stride=self._TDNN_STRIDE,
dilation=1,
padding=-1,
config_str=config_str,
),
),
]
)
)
channels = init_channels
for i, (num_layers, kernel_size, dilation) in enumerate(zip((12, 24, 16), (3, 3, 3), (1, 2, 2), strict=True)):
block = CAMDenseTDNNBlock(
num_layers=num_layers,
in_channels=channels,
out_channels=growth_rate,
bn_channels=bn_size * growth_rate,
kernel_size=kernel_size,
dilation=dilation,
config_str=config_str,
memory_efficient=memory_efficient,
)
self.xvector.add_module(f"block{i + 1}", block)
channels = channels + num_layers * growth_rate
self.xvector.add_module(
f"transit{i + 1}",
TransitLayer(channels, channels // 2, bias=False, config_str=config_str),
)
channels //= 2
self.xvector.add_module("out_nonlinear", get_nonlinear(config_str, channels))
self.xvector.add_module("stats", StatsPool())
self.xvector.add_module("dense", DenseLayer(channels * 2, embedding_size, config_str="batchnorm_"))
for m in self.modules():
if isinstance(m, (nn.Conv1d, nn.Linear)):
nn.init.kaiming_normal_(m.weight.data)
if m.bias is not None:
nn.init.zeros_(m.bias)
@staticmethod
def _conv_output_lengths(lengths, kernel_size, stride=1, padding=0, dilation=1):
return (
torch.div(
lengths + 2 * padding - dilation * (kernel_size - 1) - 1,
stride,
rounding_mode="floor",
)
+ 1
)
@staticmethod
def _make_length_mask(lengths, max_len, device):
lengths = lengths.to(device=device, dtype=torch.long).clamp(min=0, max=max_len)
return torch.arange(max_len, device=device).unsqueeze(0) < lengths.unsqueeze(1)
def _masked_stats_pooling(self, x, lengths, unbiased=True, eps=1e-2):
lengths = lengths.to(device=x.device, dtype=torch.long).clamp(min=1, max=x.size(-1))
mask = self._make_length_mask(lengths, x.size(-1), x.device).unsqueeze(1)
mask = mask.to(dtype=x.dtype)
denom = lengths.to(dtype=x.dtype).view(-1, 1).clamp_min(1.0)
mean = (x * mask).sum(dim=-1) / denom
centered = (x - mean.unsqueeze(-1)) * mask
var_denom = (lengths - 1).clamp_min(1).to(dtype=x.dtype).view(-1, 1) if unbiased else denom
var = centered.pow(2).sum(dim=-1) / var_denom
std = torch.sqrt(var.clamp_min(eps))
return torch.cat([mean, std], dim=1)
def forward(self, x, lengths=None):
x = x.permute(0, 2, 1) # (B,T,F) => (B,F,T)
x = self.head(x)
if lengths is not None:
lengths = lengths.to(device=x.device, dtype=torch.long).clamp(min=1)
for name, module in self.xvector.named_children():
if name == "stats":
x = self._masked_stats_pooling(x, lengths) if lengths is not None else module(x)
continue
x = module(x)
if name == "tdnn" and lengths is not None:
lengths = self._conv_output_lengths(
lengths,
kernel_size=self._TDNN_KERNEL_SIZE,
stride=self._TDNN_STRIDE,
padding=self._TDNN_PADDING,
)
return x
# ============================================================================
# SpeakerXVectorFeatures wrapper (from modules/speaker/encoder.py)
# ============================================================================
class SpeakerXVectorFeatures(nn.Module):
"""Speaker embedding extractor based on 3D-Speaker CAM++."""
def __init__(
self,
sample_rate=_SPEAKER_FBANK_SAMPLE_RATE,
campplus_embedding_size=512,
max_audio_seconds=10.0,
):
super().__init__()
self.sample_rate = sample_rate
self.max_audio_seconds = float(max_audio_seconds)
self.model = CAMPPlus(
feat_dim=_SPEAKER_FBANK_N_MELS,
embedding_size=campplus_embedding_size,
)
self.resample = None
if self.sample_rate != _SPEAKER_FBANK_SAMPLE_RATE:
self.resample = torchaudio.transforms.Resample(
orig_freq=sample_rate,
new_freq=_SPEAKER_FBANK_SAMPLE_RATE,
)
for param in self.model.parameters():
param.requires_grad = False
@staticmethod
def _normalize_lengths(lengths, batch_size, max_length, device, *, min_length):
if lengths is None:
return torch.full(
(batch_size,),
max_length,
device=device,
dtype=torch.long,
)
return lengths.to(device=device, dtype=torch.long).clamp(
min=min_length,
max=max_length,
)
def _crop_audio(self, audio, audio_lengths=None):
original_lengths = self._normalize_lengths(
audio_lengths,
audio.size(0),
audio.size(-1),
audio.device,
min_length=0,
)
if self.max_audio_seconds <= 0:
return audio, original_lengths, original_lengths, torch.zeros_like(original_lengths)
max_input_length = round(self.sample_rate * self.max_audio_seconds)
cropped_audio = []
cropped_lengths = []
starts = []
for index, total_length_tensor in enumerate(original_lengths):
total_length = int(total_length_tensor.item())
cropped_length = min(total_length, max_input_length)
start = random.randint(0, total_length - cropped_length) if total_length > cropped_length else 0
cropped_audio.append(audio[index, start : start + cropped_length])
cropped_lengths.append(cropped_length)
starts.append(start)
return (
pad_sequence(
cropped_audio,
batch_first=True,
padding_value=0.0,
),
original_lengths,
torch.tensor(
cropped_lengths,
device=audio.device,
dtype=torch.long,
),
torch.tensor(starts, device=audio.device, dtype=torch.long),
)
def _crop_fbank(
self,
fbank,
fbank_lengths,
original_audio_lengths,
cropped_audio_lengths,
starts,
):
original_fbank_lengths = self._normalize_lengths(
fbank_lengths,
fbank.size(0),
fbank.size(1),
fbank.device,
min_length=1,
)
cropped_fbank = []
cropped_fbank_lengths = []
for index, total_feat_length_tensor in enumerate(original_fbank_lengths):
total_audio_length = int(original_audio_lengths[index].item())
total_feat_length = int(total_feat_length_tensor.item())
start_audio = int(starts[index].item())
end_audio = start_audio + int(cropped_audio_lengths[index].item())
if total_audio_length > 0:
start_feat = math.floor(start_audio * total_feat_length / total_audio_length)
end_feat = math.ceil(end_audio * total_feat_length / total_audio_length)
else:
start_feat = 0
end_feat = 1
start_feat = min(start_feat, total_feat_length - 1)
end_feat = min(max(end_feat, start_feat + 1), total_feat_length)
cropped_fbank.append(fbank[index, start_feat:end_feat])
cropped_fbank_lengths.append(end_feat - start_feat)
return pad_sequence(
cropped_fbank,
batch_first=True,
padding_value=0.0,
), torch.tensor(
cropped_fbank_lengths,
device=fbank.device,
dtype=torch.long,
)
def _extract_fbank_batch(self, audio, audio_lengths):
if self.resample is not None:
audio = self.resample(audio)
audio_lengths = torch.ceil(audio_lengths.float() * (_SPEAKER_FBANK_SAMPLE_RATE / self.sample_rate)).long()
audio_cpu = audio.detach().cpu()
features = []
for index, valid_length_tensor in enumerate(audio_lengths):
valid_length = int(valid_length_tensor.item())
waveform = audio_cpu[index, :valid_length]
if waveform.numel() == 0:
waveform = audio_cpu.new_zeros(1)
features.append(
extract_speaker_fbank(
waveform,
sample_rate=_SPEAKER_FBANK_SAMPLE_RATE,
)
)
fbank_lengths = torch.tensor(
[feature.size(0) for feature in features],
device=audio.device,
dtype=torch.long,
)
fbank = pad_sequence(
features,
batch_first=True,
padding_value=0.0,
).to(device=audio.device, dtype=audio.dtype)
return fbank, fbank_lengths
@torch.no_grad()
@torch.autocast(enabled=False, device_type="cuda")
def forward(self, audio, audio_lengths=None, fbank=None, fbank_lengths=None, **_kwargs):
self.model.eval()
audio = audio.float()
if audio.dim() == 3:
if audio.size(1) != 1:
raise ValueError(f"Speaker encoder expects mono audio, got shape {tuple(audio.shape)}.")
audio = audio[:, 0]
elif audio.dim() != 2:
raise ValueError(f"Speaker encoder expects a 2D or 3D audio tensor, got shape {tuple(audio.shape)}.")
audio, original_audio_lengths, cropped_audio_lengths, starts = self._crop_audio(
audio,
audio_lengths=audio_lengths,
)
if fbank is None:
fbank, fbank_lengths = self._extract_fbank_batch(
audio,
cropped_audio_lengths,
)
else:
if not isinstance(fbank, torch.Tensor):
raise TypeError("Speaker encoder expects `fbank` to be a torch.Tensor.")
if fbank.dim() != 3 or fbank.size(0) != audio.size(0):
raise ValueError(
f"Speaker encoder expects `fbank` with shape (B, T, F) and matching batch size, got {tuple(fbank.shape)}." # noqa: E501
)
fbank, fbank_lengths = self._crop_fbank(
fbank.to(device=audio.device, dtype=torch.float32),
fbank_lengths,
original_audio_lengths,
cropped_audio_lengths,
starts,
)
return self.model(fbank, lengths=fbank_lengths)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,46 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""dots.tts pipeline topology (frozen).
Single-stage AR TTS: text → 48 kHz waveform in one pass.
Architecture: Qwen2.5-1.5B base LM (vLLM-native) → DiTAR / MeanFlow head
→ AudioVAE.decode → BigVGAN → 48 kHz mono.
Speaker conditioning via CAM++ x-vector injected in preprocess().
Modeled after voxcpm2's single-stage pipeline (same "vLLM-native base LM
+ side-path computation" pattern), but simplified: no residual LM, no FSQ.
"""
from vllm_omni.config.stage_config import (
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
)
DOTS_TTS_PIPELINE = PipelineConfig(
model_type="dots_tts",
model_arch="DotsTTSForConditionalGeneration",
default_deploy_config_name="dots_tts.yaml",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="latent_generator",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
final_output=True,
final_output_type="audio",
owns_tokenizer=True,
engine_output_type="audio",
sampling_constraints={
"detokenize": False,
# Stop signal slot in compute_logits (voxcpm2 convention —
# talker emits logits[i, 1] = 1.0 when prob_stop > 0.8, vLLM
# matches against this list to terminate the request). Not
# Qwen2.5 EOS (151643) — that ID would never come out of the
# 2-slot continue/stop softmax our compute_logits builds.
"stop_token_ids": [1],
},
),
),
)
@@ -301,12 +301,6 @@ _OMNI_MODELS = {
"voxcpm2_talker", "voxcpm2_talker",
"VoxCPM2TalkerForConditionalGeneration", "VoxCPM2TalkerForConditionalGeneration",
), ),
## dots.tts
"DotsTTSForConditionalGeneration": (
"dots_tts",
"dots_tts_talker",
"DotsTTSForConditionalGeneration",
),
## Voxtral TTS ## Voxtral TTS
"VoxtralTTSForConditionalGeneration": ( "VoxtralTTSForConditionalGeneration": (
"voxtral_tts", "voxtral_tts",
-1
View File
@@ -205,7 +205,6 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = {
} }
for model_class_name in ( for model_class_name in (
"LTX2Pipeline", "LTX2Pipeline",
"LTX2TwoStagePipeline",
"LTX2DistilledPipeline", "LTX2DistilledPipeline",
) )
}, },
@@ -20,7 +20,6 @@ _CLASS_TO_MODULE: dict[str, str] = {
"FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech", "FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech",
"GLMTTSConfig": "vllm_omni.transformers_utils.configs.glm_tts", "GLMTTSConfig": "vllm_omni.transformers_utils.configs.glm_tts",
"VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2", "VoxCPM2Config": "vllm_omni.transformers_utils.configs.voxcpm2",
"DotsTTSConfig": "vllm_omni.transformers_utils.configs.dots_tts",
"VoxtralTTSConfig": "vllm_omni.transformers_utils.configs.voxtral_tts", "VoxtralTTSConfig": "vllm_omni.transformers_utils.configs.voxtral_tts",
"CosyVoice3Config": "vllm_omni.transformers_utils.configs.cosyvoice3", "CosyVoice3Config": "vllm_omni.transformers_utils.configs.cosyvoice3",
"OmniVoiceConfig": "vllm_omni.transformers_utils.configs.omnivoice", "OmniVoiceConfig": "vllm_omni.transformers_utils.configs.omnivoice",
@@ -43,7 +42,6 @@ __all__ = [
"FishSpeechFastARConfig", "FishSpeechFastARConfig",
"GLMTTSConfig", "GLMTTSConfig",
"VoxCPM2Config", "VoxCPM2Config",
"DotsTTSConfig",
"VoxtralTTSConfig", "VoxtralTTSConfig",
"CosyVoice3Config", "CosyVoice3Config",
"OmniVoiceConfig", "OmniVoiceConfig",
@@ -72,7 +70,6 @@ def __dir__():
# Eagerly import all config modules so their AutoConfig.register() side-effects # Eagerly import all config modules so their AutoConfig.register() side-effects
# run as soon as `vllm_omni.transformers_utils.configs` is imported. # run as soon as `vllm_omni.transformers_utils.configs` is imported.
from vllm_omni.transformers_utils.configs import cosyvoice3 as _cosyvoice3 # noqa: F401, E402 from vllm_omni.transformers_utils.configs import cosyvoice3 as _cosyvoice3 # noqa: F401, E402
from vllm_omni.transformers_utils.configs import dots_tts as _dots_tts # noqa: F401, E402
from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402 from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402
from vllm_omni.transformers_utils.configs import glm_tts as _glm_tts # noqa: F401, E402 from vllm_omni.transformers_utils.configs import glm_tts as _glm_tts # noqa: F401, E402
from vllm_omni.transformers_utils.configs import higgs_audio_v3 as _higgs_audio_v3 # noqa: F401, E402 from vllm_omni.transformers_utils.configs import higgs_audio_v3 as _higgs_audio_v3 # noqa: F401, E402
@@ -1,102 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""HF config for dots.tts.
dots.tts splits its config across two files in the HF checkpoint:
* ``config.json`` — top-level dots.tts fields (latent_dim / patch_size
/ PatchEncoder / DiT / vocoder / etc.).
* ``llm_config.json`` — full Qwen2 LM config (vocab_size / hidden_size /
num_hidden_layers / rope_theta / etc.).
``AutoConfig.from_pretrained`` only consumes ``config.json``: its dots.tts
sub-blocks (``vocoder`` / ``DiT`` / ``PatchEncoder``) arrive through
``**kwargs`` and land as attributes, which the talker's config factories
consume directly. The Qwen2 LM fields live in ``llm_config.json``, which
AutoConfig never reads — this class hoists them to top-level attributes
with defaults matching the released checkpoints (soar / base / mf all ship
the same Qwen2.5-1.5B backbone). vLLM's ``Qwen2Model.__init__`` then reads
``config.hidden_size`` / ``config.vocab_size`` / etc. through
``get_text_config()``, the same pattern voxcpm2 follows.
Future work: read ``llm_config.json`` during ``from_pretrained`` so a
checkpoint with a different LM shape works without editing this file.
Upstream reference:
https://huggingface.co/rednote-hilab/dots.tts-soar/blob/main/llm_config.json
"""
from __future__ import annotations
from transformers import AutoConfig
from transformers.configuration_utils import PretrainedConfig
class DotsTTSConfig(PretrainedConfig):
"""Configuration for dots.tts integration (Qwen2.5-1.5B-Base AR backbone)."""
model_type = "dots_tts"
keys_to_ignore_at_inference = ["past_key_values"]
def __init__(
self,
# -- top-level dots.tts params --
lm_config: dict | None = None,
# -- LM defaults (overridden by lm_config dict if present) --
# All values match dots.tts-soar's llm_config.json.
vocab_size: int = 151672,
hidden_size: int = 1536,
intermediate_size: int = 8960,
num_hidden_layers: int = 28,
num_attention_heads: int = 12,
num_key_value_heads: int = 2,
max_position_embeddings: int = 131072,
rms_norm_eps: float = 1e-6,
rope_theta: float = 1_000_000.0,
tie_word_embeddings: bool = True,
hidden_act: str = "silu",
bos_token_id: int = 151643,
eos_token_id: int = 151643,
**kwargs,
):
super().__init__(
bos_token_id=bos_token_id,
eos_token_id=eos_token_id,
tie_word_embeddings=tie_word_embeddings,
**kwargs,
)
# The dots.tts sub-blocks (vocoder / DiT / PatchEncoder / latent_dim
# / patch_size / ...) arrive via **kwargs and are stored as
# attributes by PretrainedConfig; the talker's config factories
# read them from there.
self.lm_config = lm_config or {}
# -- Hoist LM parameters to top-level for vLLM's Qwen2Model --
lm = self.lm_config
self.vocab_size = lm.get("vocab_size", vocab_size)
self.hidden_size = lm.get("hidden_size", hidden_size)
self.intermediate_size = lm.get("intermediate_size", intermediate_size)
self.num_hidden_layers = lm.get("num_hidden_layers", num_hidden_layers)
self.num_attention_heads = lm.get("num_attention_heads", num_attention_heads)
self.num_key_value_heads = lm.get("num_key_value_heads", num_key_value_heads)
self.max_position_embeddings = lm.get("max_position_embeddings", max_position_embeddings)
self.rms_norm_eps = lm.get("rms_norm_eps", rms_norm_eps)
self.rope_theta = lm.get("rope_theta", rope_theta)
self.hidden_act = lm.get("hidden_act", hidden_act)
self.head_dim = self.hidden_size // self.num_attention_heads
# vLLM's Qwen2Model reads ``max_window_layers`` and checks against
# ``num_hidden_layers`` only when ``use_sliding_window`` is true.
# soar disables sliding window — set both to the same value so the
# assertion (if executed) stays vacuous.
self.use_sliding_window = lm.get("use_sliding_window", False)
self.sliding_window = lm.get("sliding_window", None)
self.max_window_layers = lm.get("max_window_layers", self.num_hidden_layers)
def get_text_config(self, **kwargs):
"""Return self — LM fields are hoisted to top-level attributes."""
return self
AutoConfig.register("dots_tts", DotsTTSConfig)
__all__ = ["DotsTTSConfig"]