Compare commits

...
Author SHA1 Message Date
Hongsheng Liu 01e7d09119 docs: define module ownership contracts
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-07 11:45:59 +08:00
Wang fuyin 3d7fc3b9ba [Config] Read engine args from VllmOmniConfig (#5678)
Signed-off-by: Acerak01-fy <wfy2003324@163.com>
Signed-off-by: Wang  fuyin <wfy2003324@163.com>
2026-08-07 11:17:33 +08:00
6ec9033065 [Model] MiniCPM-o 4.5: reuse FlashAttention unpadding metadata (#5165)
Signed-off-by: 道路自信 <182356120+daoluzixin@users.noreply.github.com>
Co-authored-by: 道路自信 <182356120+daoluzixin@users.noreply.github.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-07 10:25:04 +08:00
WeiQing Chen a874b8e09b [Perf][CI] Add MiniMax-H3 4xH100 diffusion perf config (#5836)
Signed-off-by: David Chen <530634352@qq.com>
2026-08-07 00:12:07 +08:00
Hongsheng Liu 62bf8d630d docs: document distributed layerwise offload compatibility (#5839)
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-06 23:21:43 +08:00
wangyuandamy-why-3459 4b625d1175 [CI/Build] Resolve hardware-nested perf baselines per concurrency sweep (#5845)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <your.email@example.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-06 21:20:04 +08:00
48298030d4 [Diffusion] Add Minimax-H3 modular pipeline support (#5720)
Signed-off-by: Isotr0py <Isotr0py@outlook.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Signed-off-by: David Chen <530634352@qq.com>
Co-authored-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: David Chen <530634352@qq.com>
2026-08-06 11:43:23 +00:00
d219d93bbb [Attention] Refine TRTLLM attention support for MiniMax H3 (#5779)
Signed-off-by: Bo Li <22713281+bobboli@users.noreply.github.com>
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
Co-authored-by: lishunyang12 <lishunyang12@163.com>
2026-08-06 09:38:17 +00:00
Xunzhuo a9ba47d8ce [Perf] Bound memory during video frame conversion (#5732)
Signed-off-by: Xunzhuo Liu <xunzhuo.liu@amd.com>
2026-08-06 14:41:59 +08:00
Zijun HuangandWeiQing Chen 93457f29ff [NPU][Quantization] Add RainFusion attention and INT8 online quantization for MiniMax H3 (#5706)
Signed-off-by: Zijun Huang <zijunhua@usc.edu>
Co-authored-by: WeiQing Chen <40507679+david6666666@users.noreply.github.com>
2026-08-06 14:15:21 +08:00
hsliuustc0106 69766960f2 docs: update module design navigation
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-07-16 14:09:07 +08:00
hsliuustc0106 80fd96c1da docs: reorganize module design documents
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-07-16 12:08:45 +08:00
115 changed files with 6621 additions and 504 deletions

No files matched your search

+15
View File
@@ -460,6 +460,21 @@ steps:
exit $$EXIT2 exit $$EXIT2
mirror_hardwares: h100_2 mirror_hardwares: h100_2
- label: ":full_moon: Diffusion X2V · Perf Test · MiniMax-H3"
key: nightly-diffusion-x2v-performance-minimax-h3
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_minimax_h3_vllm_omni.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
mirror_hardwares: h100_4
- group: ":card_index_dividers: Diffusion Test" - group: ":card_index_dividers: Diffusion Test"
key: nightly-diffusion-test-group key: nightly-diffusion-test-group
depends_on: upload-nightly-pipeline depends_on: upload-nightly-pipeline
+1 -1
View File
@@ -309,7 +309,7 @@ For Omni or custom models, create:
Required updates: Required updates:
1. `docs/user_guide/diffusion/parallelism/overview.md` — parallelism support overview/table 1. `docs/user_guide/diffusion/parallelism/overview.md` — parallelism support overview/table
2. `docs/user_guide/diffusion/cpu_offload_diffusion.md` — if CPU offload supported (add to supported models table) 2. `docs/user_guide/diffusion/cpu_offload.md` — if CPU offload supported (add to supported models table)
3. `docs/user_guide/diffusion/cache_acceleration/teacache.md` — if TeaCache supported 3. `docs/user_guide/diffusion/cache_acceleration/teacache.md` — if TeaCache supported
4. `docs/user_guide/diffusion/cache_acceleration/cache_dit.md` — if Cache-DiT supported 4. `docs/user_guide/diffusion/cache_acceleration/cache_dit.md` — if Cache-DiT supported
5. Offline example docs under `examples/offline_inference/<name>/` (`README.md` or category-specific `.md`) 5. Offline example docs under `examples/offline_inference/<name>/` (`README.md` or category-specific `.md`)
+20
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import base64 import base64
import json
import mimetypes import mimetypes
import os import os
import time import time
@@ -13,6 +14,17 @@ from tqdm import tqdm
DEFAULT_EDITS_BOT_TASK = "think" DEFAULT_EDITS_BOT_TASK = "think"
def _is_minimax_h3_mixed_reference(model: str) -> bool:
"""MiniMax-H3 Ref2VA consumes reference videos as file paths.
Its reference preparer ffprobes / ffmpegs the reference file, so single
``video_reference`` uploads are sent as a base64 data URL: the server
persists them to disk and keeps ``source_path``, handing the pipeline a
real file path instead of a decoded frame list.
"""
return "MiniMax-H3" in (model or "")
@dataclass @dataclass
class RequestFuncInput: class RequestFuncInput:
prompt: str prompt: str
@@ -355,6 +367,14 @@ async def async_request_v1_videos(
) )
elif input.video_paths and len(input.video_paths) > 0: elif input.video_paths and len(input.video_paths) > 0:
video_path = input.video_paths[0] video_path = input.video_paths[0]
if _is_minimax_h3_mixed_reference(input.model):
with open(video_path, "rb") as reference_file:
video_b64 = base64.b64encode(reference_file.read()).decode("ascii")
form.add_field(
"video_reference",
json.dumps([{"video_url": f"data:video/mp4;base64,{video_b64}"}]),
)
else:
image_file = open(video_path, "rb") image_file = open(video_path, "rb")
form.add_field( form.add_field(
"input_reference", "input_reference",
@@ -858,7 +858,12 @@ class RandomDataset(BaseDataset):
height = int(self.args.height or profile.get("height") or 720) height = int(self.args.height or profile.get("height") or 720)
num_frames = int(self.args.num_frames or profile.get("num_frames") or 16) num_frames = int(self.args.num_frames or profile.get("num_frames") or 16)
fps = float(self.args.fps or profile.get("fps") or 8) fps = float(self.args.fps or profile.get("fps") or 8)
writer = cv2.VideoWriter(video_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height)) # cv2 typically writes MPEG-4 Part 2 (``mp4v``), which pipelines such
# as MiniMax-H3 Ref2VA reject because they require H.264/H.265
# reference videos. Write the synthetic clip first, then re-encode
# it to H.264 when ffmpeg is available; otherwise keep ``mp4v``.
raw_path = video_path + ".raw.mp4"
writer = cv2.VideoWriter(raw_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
if not writer.isOpened(): if not writer.isOpened():
raise RuntimeError("cv2.VideoWriter failed to open") raise RuntimeError("cv2.VideoWriter failed to open")
for frame_idx in range(num_frames): for frame_idx in range(num_frames):
@@ -871,6 +876,10 @@ class RandomDataset(BaseDataset):
frame[max(y - 36, 0) : min(y + 36, height), x : min(x + 96, width), :] = (64, 128, 220) frame[max(y - 36, 0) : min(y + 36, height), x : min(x + 96, width), :] = (64, 128, 220)
writer.write(frame) writer.write(frame)
writer.release() writer.release()
if _reencode_video_to_h264(raw_path, video_path):
os.unlink(raw_path)
else:
os.replace(raw_path, video_path)
return [video_path] return [video_path]
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
@@ -878,6 +887,40 @@ class RandomDataset(BaseDataset):
) from e ) from e
def _reencode_video_to_h264(source: str, destination: str) -> bool:
"""Re-encode a video to H.264/MP4 using ffmpeg when available.
Returns False when ffmpeg is missing or the re-encode fails so callers can
fall back to the original container/codec.
"""
import shutil
import subprocess
if shutil.which("ffmpeg") is None:
return False
try:
result = subprocess.run(
[
"ffmpeg",
"-y",
"-loglevel",
"error",
"-i",
source,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
destination,
],
capture_output=True,
text=True,
)
return result.returncode == 0 and os.path.exists(destination)
except Exception:
return False
def _compute_expected_latency_ms_from_base(req: RequestFuncInput, args, base_time_ms: float | None) -> float | None: def _compute_expected_latency_ms_from_base(req: RequestFuncInput, args, base_time_ms: float | None) -> float | None:
"""Compute expected execution time (ms) based on a base per-step-per-frame unit time. """Compute expected execution time (ms) based on a base per-step-per-frame unit time.
+26 -10
View File
@@ -68,12 +68,12 @@ nav:
- 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 Parallelism: user_guide/diffusion/parallelism/vae_parallelism.md
- CPU Offloading: user_guide/diffusion/cpu_offload_diffusion.md - CPU Offloading: user_guide/diffusion/cpu_offload.md
- LoRA: user_guide/diffusion/lora.md - LoRA: user_guide/diffusion/lora.md
- Custom Pipeline: features/custom_pipeline.md - Custom Pipeline: features/custom_pipeline.md
- Execution Modes: user_guide/diffusion/execution_modes.md - Execution Modes: user_guide/diffusion/execution_modes.md
- Quantization: - Quantization:
- Overview: user_guide/quantization/overview.md - Quantization: user_guide/quantization/overview.md
- Online Quantization: user_guide/quantization/online.md - Online Quantization: user_guide/quantization/online.md
- Quantized KV Cache: user_guide/quantization/quantized_kvcache.md - Quantized KV Cache: user_guide/quantization/quantized_kvcache.md
- FP8 W8A8: user_guide/quantization/fp8.md - FP8 W8A8: user_guide/quantization/fp8.md
@@ -106,7 +106,6 @@ nav:
- Feature Design: - Feature Design:
- Runtime and Stage Execution: - Runtime and Stage Execution:
- Disaggregated Inference: design/feature/disaggregated_inference.md - Disaggregated Inference: design/feature/disaggregated_inference.md
- Ray-based Execution: design/feature/ray_based_execution.md
- Async Chunk: design/feature/async_chunk.md - Async Chunk: design/feature/async_chunk.md
- Async Diffusion Output: design/feature/async_diffusion_output.md - Async Diffusion Output: design/feature/async_diffusion_output.md
- Async Omni Output Materialization: design/feature/omni_async_output_materialization.md - Async Omni Output Materialization: design/feature/omni_async_output_materialization.md
@@ -130,20 +129,37 @@ nav:
- VAE Patch Parallelism: design/feature/vae_parallel.md - VAE Patch Parallelism: design/feature/vae_parallel.md
- Attention Backends: - Attention Backends:
- Skip-Softmax: design/feature/skip_softmax.md - Skip-Softmax: design/feature/skip_softmax.md
- Quantization: user_guide/quantization/overview.md - Quantization: design/feature/quantization.md
- Cache-DiT: design/feature/cache_dit.md - Cache-DiT: design/feature/cache_dit.md
- TeaCache: design/feature/teacache.md - TeaCache: design/feature/teacache.md
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md - Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md
- Distributed Layerwise Offload: design/feature/distributed_layerwise_offload.md
- Infrastructure and Performance: - Infrastructure and Performance:
- Prometheus Metrics: design/metrics.md - Prometheus Metrics: design/metrics.md
- Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md - Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md
- Module Design: - Module Design:
- Runtime Modules: - Entrypoints: design/module/entrypoints.md
- AR Module: design/module/ar_module.md - vLLM-Omni Configuration: design/module/vllm_omni_config.md
- DIT Module: design/module/dit_module.md - Input, Output, and Modality Contracts: design/module/input_output_modality_contracts.md
- Orchestration: - Error Contracts: design/module/error_contracts.md
- Entrypoint Module: design/module/entrypoint_module.md - Engine Orchestration: design/module/engine_orchestration.md
- AsyncOmni Architecture: design/module/async_omni_architecture.md - Stage Runtime: design/module/stage_runtime.md
- OmniConnector: design/module/omni_connector.md
- Model Integration: design/module/model_integration.md
- Autoregressive Runtime: design/module/ar_runtime.md
- Diffusion:
- Overview: design/module/diffusion/index.md
- Runtime: design/module/diffusion/diffusion_runtime.md
- Model Integration: design/module/diffusion/diffusion_model_integration.md
- Continuous Batching: design/module/diffusion/continuous_batching.md
- Parallelism: design/module/diffusion/parallelism.md
- Offloader: design/module/diffusion/offloader.md
- Execution Platforms: design/module/execution_platforms.md
- Cache Management: design/module/cache_management.md
- Quantization: design/module/quantization.md
- Observability: design/module/observability.md
- Profiling: design/module/profiling.md
- Benchmarking: design/module/benchmarking.md
- Docs Guide: contributing/DOCS_GUIDE.md - Docs Guide: contributing/DOCS_GUIDE.md
- API Reference: - API Reference:
- api/README.md - api/README.md
@@ -1,6 +1,6 @@
**Scope** **Scope**
For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload_diffusion/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/quantization/overview/). For diffusion models, the L4 functionality test covers all or common *diffusion features* that are supported by this model, including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/quantization/overview/).
**Test Case Design** **Test Case Design**
@@ -200,4 +200,4 @@ You can add any benchmark running parameters you need here. For all optional par
| num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. | | num_prompts | int / array | Yes | 10,[10, 20, 30] | Number of requests. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of qps or max_concurrency, e.g., [10,10,10]. If an array is used, its length must match the number of qps or max_concurrency. |
| request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. | | request_rate | float / array | No | 0.5, [0.5, 1, inf] | Queries per second. Supports single values or arrays. If a single value is used, it will be automatically expanded to match the number of num_prompts, e.g., [1,1,1]. If an array is used, its length must match the number of num_prompts. |
| max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). | | max_concurrency | int / array | No | 1, [1, 2, 3] | Maximum concurrent in-flight requests. Same array / expansion rules as `request_rate` (mutually exclusive with QPS mode). |
| baseline | object | No | see below | Optional thresholds. Prefer hardware-nested form: `{"H100": {"throughput_qps": …}, "A3": {…}}` (selected via runtime hardware label). Legacy flat `{metric: threshold}` still works. Per metric: scalar, list (sweep step), or object keyed by concurrency / QPS. | baseline | object | No | see below | Optional thresholds. **Hardware-nested only**: top-level keys must be `[hardware-resource]` pytest markers from `pyproject.toml` (`get_hardware_mark_list()`). Under each hardware key, metric names are free-form; values are scalars or sweep-aligned lists. Example: `{"H100": {"throughput_qps": […], "custom_stage_ms": 1.0}, "A3": {…}}`. Runtime filename aliases (`_RUNTIME_DEVICE_ALIASES`) are separate and do not expand this allowlist. |
@@ -824,7 +824,7 @@ omni = Omni(model="your-model",
### CPU Offload ### CPU Offload
See detailed guide: [CPU Offloading for Diffusion Models](../../user_guide/diffusion/cpu_offload_diffusion.md) See detailed guide: [CPU Offloading for Diffusion Models](../../user_guide/diffusion/cpu_offload.md)
vLLM-Omni provides two offloading strategies to reduce GPU memory usage: vLLM-Omni provides two offloading strategies to reduce GPU memory usage:
@@ -0,0 +1,156 @@
# Distributed Layerwise Offload
This document describes distributed layerwise offload (DLO) for diffusion
models. DLO keeps only a small number of DiT blocks on the accelerator and
streams the remaining blocks from host memory. The distributed backend can
either shard those host-side weights across an existing parallel group or keep
the standard loader's rank-local weights and avoid an additional collective.
For user-facing commands, see the
[distributed layerwise offloading guide](../../user_guide/diffusion/cpu_offload.md)
and the [Cosmos3 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md).
## Status
DLO is implemented for multi-device diffusion execution. The default
AllGather path is the primary path for DP and SP deployments. The
`--dlo-no-use-allgather` path is a rank-local compatibility mode: it is useful
for standard-loader sharding, workstation bring-up, and systems where an
additional DLO collective is undesirable, but it does not reduce host weight
storage across ranks.
The compatibility matrix below describes the current implementation. The
unit-level guards are covered, but not every parallelism combination has a
full model-and-hardware end-to-end test.
## Design
### DLO consumes the existing parallel topology
DLO does not create a new DP, TP, or SP topology. It reads the configured
`DiffusionParallelConfig` and attaches offload hooks to the DiT blocks after the
standard distributed groups have been initialized.
The DLO weight-sharding group is selected as follows:
1. Use the existing DP group when `data_parallel_size > 1`.
2. When DP is one and SP is greater than one, use the SP group.
3. Otherwise, run rank-locally without a DLO process group.
TP is deliberately not used as DLO's AllGather group. HSDP has its own
parameter-sharding lifecycle and is not allowed to be sharded a second time by
DLO's AllGather path.
### AllGather path
With the default `dlo_use_allgather=True`, each rank stores approximately
`1 / group_size` of each streamable block in pinned host memory. The next
block's shard is copied to a device buffer and reconstructed with
`all_gather_into_tensor` on a communication stream while the current block is
executing.
```text
Compute: [Block N] [Block N+1] [Block N+2]
H2D: [shard N+1] [shard N+2]
AllGather: [full N+1] [full N+2]
Buffers: [current slot] [prefetch slot] [current slot]
```
![DLO double-buffer prefetch pipeline](../figures/dlo/dlo_pipeline.gif)
The backend uses two shared device buffers, so accelerator weight residency is
bounded by the largest streamed blocks rather than the complete model.
When DP is greater than one, the engine can process one request per DP rank in
the same denoising wave. Because AllGather is a collective, all participating
requests must take the same execution path at every denoising step.
### Rank-local path without DLO AllGather
With `--dlo-no-use-allgather`, DLO forces its internal offload shard size to
one. The regular model loader remains responsible for preparing each rank's
weights, including TP-local tensors or HSDP-managed parameters. DLO then
streams those rank-local tensors block by block using H2D copies only.
This mode means:
- DP still provides independent replicas, but DLO does not shard weights
across DP ranks.
- SP still performs its normal activation/attention collectives, but DLO does
not shard weights across SP ranks.
- TP/HSDP/SP collectives, if configured, are not disabled by this flag; only
DLO's additional weight AllGather is disabled.
- Pure DP deployments keep a full host-side model copy per rank, subject to
shared OS page-cache behavior.
- The scheduler does not require a synchronized DP request wave for DLO.
This path is intentionally not implemented by reusing the AllGather mmap
loader. It relies on the standard loader so model-specific TP/HSDP transforms
remain intact.
## Parallelism compatibility
| Parallelism | DLO + AllGather | DLO without AllGather |
|---|---|---|
| **DP** | Supported primary path. DLO shards host weights across the DP group and can run DP multi-concurrency. | Supported rank-local path. DP replicas remain independent; no DLO weight sharding or cross-DP DLO collective. |
| **SP** | Supported in the implementation. With DP=1, DLO uses the SP group for host-weight sharding; SP still shards sequence/activation work. | SP remains active, but DLO keeps standard-loader rank-local weights and adds no SP weight collective. |
| **TP > 1** | Unsupported in the DLO mmap path. TP-aware loader callbacks are bypassed, so the backend rejects this configuration when it enters that path. | Standard loading is retained and TP-local tensors can be streamed. This is the intended compatibility path, but it still needs broader model and hardware validation. |
| **HSDP** | Rejected. HSDP has already sharded parameters, so DLO AllGather would double-shard them. | Accepted by configuration. HSDP owns parameter sharding and its own gathers; DLO only stages rank-local parameters. End-to-end coverage is limited. |
### Combined dimensions
- **DP + SP:** DLO uses the DP group for weight sharding when DP is greater
than one; SP continues to use its own sequence-parallel group. If DP is one,
the SP group becomes DLO's sharding group in AllGather mode.
- **DP + TP/SP without AllGather:** standard model loading defines the
rank-local tensor layout. DLO adds no cross-DP, cross-TP, or cross-SP weight
collective.
- **HSDP + SP:** the general parallel configuration permits HSDP over SP, but
DLO must use `--dlo-no-use-allgather`. HSDP remains responsible for weight
materialization and synchronization.
- **HSDP + DP or TP:** rejected independently by the diffusion parallel
configuration.
## Request and loading constraints
AllGather DP multi-concurrency requires:
- explicit `num_inference_steps`;
- the same `num_inference_steps` for all requests in a wave; and
- identical request arguments that affect the collective execution path.
The no-AllGather path does not impose these DLO-specific synchronized-wave
requirements.
The mmap loader is used only by the supported DLO+AllGather path when the
model has a compatible checkpoint layout. Online quantization is incompatible
with that sharded mmap path; use `--dlo-no-use-allgather` or disable online
quantization. Models that do not meet the mmap requirements use the regular
loader path.
## Validation coverage
Current source-level validation includes:
- HSDP + DLO + AllGather rejection;
- HSDP + DLO without AllGather acceptance at configuration level;
- TP rejection in the DLO+AllGather mmap path;
- resident-layer requests requiring no-AllGather;
- DP request-wave validation for denoising-step compatibility;
- sharding, double-buffer, AllGather-size, and heterogeneous-block regression
tests.
The highest-value missing coverage is end-to-end numerical comparison against
ordinary layerwise offload for DP+SP, TP+no-AllGather, and HSDP+SP+no-AllGather
on the target CUDA/NCCL or CANN/HCCL hardware.
## Recommendations
- Use **DP + DLO AllGather** for the supported throughput and host-memory
scaling path.
- Use **SP + DLO AllGather** for long-sequence workloads when DP concurrency is
not the goal.
- Use **no-AllGather** to bring up TP or workstation PCIe configurations, with
the expectation of higher host-memory use and lower validation confidence.
- Prefer **HSDP alone** for production HSDP deployments until the combined
HSDP + DLO no-AllGather path has broader end-to-end coverage.
+249
View File
@@ -0,0 +1,249 @@
# Quantization
This document describes the quantization architecture in vLLM-Omni and the
extension points for contributors. For user-facing configuration, supported
hardware, and method-specific instructions, see the
[quantization user guide](../../user_guide/quantization/overview.md).
## Goals and scope
The design has four goals:
1. Provide one `quantization_config` entry point for diffusion-only,
multi-stage omni/TTS, and multi-stage diffusion models.
2. Reuse vLLM's quantization registry and `QuantizationConfig` contract where
a vLLM backend already provides the required behavior.
3. Allow different components of one pipeline to use different quantization
configurations.
4. Preserve the distinction between online quantization of a BF16/FP16
checkpoint and loading a pre-quantized checkpoint with serialized scales.
This document covers configuration resolution, component routing, model
integration, backend extension, and validation. Kernel implementations and
user-facing support matrices are outside its scope.
## Architecture
The quantization path is split across the following layers:
| Layer | Responsibility | Source |
| --- | --- | --- |
| Public API | Exposes `build_quant_config`, component routing, and backend registration | `vllm_omni/quantization/__init__.py` |
| Factory | Normalizes method names, selects Omni overrides or vLLM registry backends, detects ModelOpt configs, and reconciles checkpoint metadata | `vllm_omni/quantization/factory.py` |
| Component router | Resolves a quantization config from a layer prefix using longest-prefix matching | `vllm_omni/quantization/component_config.py` |
| Method configs | Implement `QuantizationConfig` and select the platform-specific linear method | `vllm_omni/quantization/*_config.py` |
| Diffusion configuration | Stores the canonical runtime config and propagates checkpoint configuration | `vllm_omni/diffusion/data.py` |
| Worker and model integration | Passes the resolved config into vLLM's model configuration and model layers | `vllm_omni/diffusion/worker/diffusion_worker.py` and `vllm_omni/diffusion/models/` |
The runtime contract remains vLLM's `QuantizationConfig`:
```text
user/CLI or model config
|
v
OmniDiffusionConfig.quantization_config
|
+--> build_quant_config()
| |
| +--> Omni override
| +--> vLLM registry backend
| +--> ComponentQuantizationConfig
|
+--> checkpoint metadata reconciliation
|
+--> diffusion worker / vLLM config
|
+--> model layers call get_quant_method(layer, prefix)
```
The model layer receives either a method implementation or `None`. Returning
`None` leaves that layer unquantized.
## Configuration resolution
`build_quant_config()` accepts the following forms:
| Input | Resolution |
| --- | --- |
| `None` or `"none"` | Disable quantization. |
| Method string, such as `"fp8"` | Resolve an Omni override first, then the vLLM quantization registry. |
| Flat dictionary with `method` or `quant_method` | Normalize the method and construct its `QuantizationConfig`. |
| Per-component dictionary | Construct a `ComponentQuantizationConfig`. |
| Existing `QuantizationConfig` | Pass through without rebuilding it. |
Method aliases are normalized case-insensitively, with `-` and `_` treated as
equivalent. `auto-round` and `auto_round` use the Omni INC/AutoRound adapter.
ModelOpt checkpoint metadata is detected from its method, producer metadata, or
`quant_algo`, then mapped to the corresponding vLLM ModelOpt configuration.
The factory uses Omni-specific overrides for methods whose diffusion behavior
or platform dispatch is not provided by the vLLM registry. The current override
set includes INT8, BitsAndBytes, MXFP8, MXFP4, MXFP4 dual-scale, and INC/
AutoRound. Other methods, such as FP8, GGUF, and ModelOpt, are resolved through
vLLM's registry when their configuration is compatible.
## Per-component routing
Multi-stage models do not necessarily want one quantization policy for every
module. A component map expresses that policy without requiring each pipeline
to construct separate vLLM configurations:
```python
quantization_config = {
"transformer": {"method": "fp8"},
"vae": None,
"default": None,
}
```
`ComponentQuantizationConfig` applies the following rules:
1. Component keys are interpreted as runtime layer-prefixes.
2. The longest matching prefix wins.
3. `None` disables quantization for the matching component.
4. `default`, when present, handles prefixes with no explicit match.
5. A matching child config delegates to its normal
`get_quant_method(layer, prefix)` implementation.
Prefix matching happens after the model's weight-name mapping. New model
integrations must verify the actual runtime prefixes, especially when a
`WeightsMapper` changes checkpoint names. A prefix mismatch can silently route
a layer to the default configuration, so component-routing tests should cover
both positive matches and fall-through behavior.
## Online and pre-quantized checkpoints
The same method name can represent two different loading paths:
- **Online**: the source checkpoint contains BF16/FP16 weights; the method
creates quantized weights or scales while the model is loaded.
- **Pre-quantized**: the checkpoint contains quantized weights and the metadata
required by the method; the loader consumes those serialized tensors.
Method configuration owns the mode flag, for example
`is_checkpoint_int8_serialized` or `is_checkpoint_mxfp8_serialized`. The
configuration's `get_quant_method()` selects the corresponding linear method.
For pipelines with transformer-specific `config.json` files, such as cascade
models, `resolve_quant_config_from_disk()` reconciles the active configuration
with each transformer's checkpoint metadata. Its invariants are:
- If no active configuration exists, valid checkpoint metadata can be used for
auto-detection.
- A method mismatch is an error rather than an implicit conversion.
- Serialized-checkpoint flags rebuild an online configuration into the
matching offline configuration.
- Checkpoint-specific `ignored_layers` are preserved by rebuilding the active
configuration when they differ.
- AutoRound MXFP8 metadata (`data_type="mx_fp"`) selects its offline path.
This prevents a model from interpreting serialized weights with an online
loader or silently applying the wrong method to one transformer in a
multi-transformer pipeline.
## Model and layer integration
Diffusion model constructors receive the resolved configuration and pass it to
quantizable vLLM layers. Each method configuration is responsible for:
- identifying supported layers in `get_quant_method()`;
- creating the correct parameter shapes and loaders;
- processing weights after loading when conversion is required; and
- applying the platform-specific quantized operation at runtime.
Model code must keep precision-sensitive or unsupported modules unquantized.
The shared `safe_quant_config()` helper is used by model integrations for
normalization and modulation layers where a generic FP8 configuration would be
unsafe. Text encoders, audio/vision encoders, VAEs, schedulers, and tokenizers
are not implicitly quantized just because the main transformer is quantized;
they require explicit component routing and method support.
Weight quantization and quantized KV cache are separate paths. The former is
resolved through `quantization_config`; runtime attention/KV-cache quantization
is configured by the diffusion attention configuration and must be validated
independently.
## Platform and parallelism boundaries
Platform checks belong in the method implementation rather than in the common
factory. This keeps configuration parsing portable while allowing CUDA, NPU,
and XPU methods to select different kernels or reject unsupported execution at
model construction time. The user guide is the source of truth for the current
hardware support matrix.
Quantization methods must also respect the weight layout produced by the
configured parallelism strategy:
- Tensor-parallel layers may receive partitioned weight shapes and loaders.
- Sequence-parallel execution changes activation movement but does not by
itself change the quantization configuration.
- Data-parallel replicas build their own method state; quantization metadata
must be available consistently on every rank.
- `WeightsMapper` transformations must be applied before matching ignored-layer
lists or component prefixes.
Distributed layerwise offload has an additional boundary: its sharded mmap
AllGather path is not compatible with online quantization because the path
expects already materialized checkpoint weights. Use the rank-local
`--dlo-no-use-allgather` path or a compatible pre-quantized checkpoint when
combining these features. See the [DLO design document](distributed_layerwise_offload.md)
for the full compatibility matrix.
## Adding a quantization backend
Contributors should follow this sequence:
1. Check whether an existing vLLM backend can provide the required weight
format and kernel behavior. Prefer reuse over a duplicate Omni backend.
2. If Omni-specific behavior is required, implement a `QuantizationConfig` and
its `QuantizeMethodBase`/linear method in `vllm_omni/quantization/`.
3. Implement `get_name()`, supported activation dtypes, minimum capability,
checkpoint parsing, ignored-layer handling, and `get_quant_method()`.
4. Add a lazy factory override in `factory.py`. Keep optional platform
dependencies out of package import paths and normalize aliases to one
canonical method name.
5. If the method is pre-quantized, define the checkpoint metadata and the
serialized loading path before adding an online path.
6. Add model-specific component routing only when the model contains stages
that must use different policies.
7. Document user-visible configuration in the quantization user guide and keep
implementation rationale here.
The public factory registration hook,
`register_quantization_override(method, builder)`, is intended for integrations
that need to register an Omni-compatible builder without changing the vLLM
registry.
## Validation
Validation should cover the complete path, not only whether a config object can
be instantiated:
| Area | Required checks |
| --- | --- |
| Factory | Strings, aliases, flat dictionaries, invalid inputs, passthrough, and component maps |
| Method config | Online/offline selection, ignored layers, platform guards, and weight loading |
| Checkpoint metadata | Auto-detection, method mismatch errors, serialized flags, and per-transformer metadata |
| Model integration | Correct quantization scope, unquantized components, and parallel weight layouts |
| Numerical quality | Same-seed BF16 versus quantized output comparison at representative resolutions and step counts |
| Performance | Peak device memory, generation latency, and any backend-specific throughput metric |
The unit tests live under `tests/diffusion/quantization/`. The trajectory
similarity tool in
`vllm_omni/quantization/tools/compare_diffusion_trajectory_similarity.py`
provides a repeatable quality and memory comparison for diffusion models.
Method support and feature combinations remain model- and hardware-dependent;
passing factory tests alone does not establish production support.
## Design invariants
- `quantization_config` is the single runtime source of truth after config
normalization.
- A checkpoint method mismatch fails loudly instead of risking weight
corruption.
- Components without an explicit supported quantization path remain in their
original precision.
- Optional backend dependencies are imported lazily.
- Method implementations own platform-specific capability checks and kernels.
- User-facing support claims require model, hardware, numerical, and memory
validation in addition to configuration-level tests.
@@ -1,59 +0,0 @@
# Distributed utils
This directory (vllm_omni/distributed/ray_utils) contains utilities for distributed execution in vllm-omni, supporting both **Ray** and **Multiprocessing** backends.
## 1. Installation
```bash
pip install "ray[default]"
```
## 2. Ray Utils
The `ray_utils` module provides helper functions for managing Ray clusters and actors, which is essential for:
* **Multi-node deployment**: Running pipeline stages across different physical machines.
* **Resource management**: Efficient GPU/CPU allocation.
### 2.1 Basic Usage
To use the Ray backend, specify `worker_backend="ray"` when initializing the engine.
**Command Line Example:**
```bash
vllm serve Qwen/Qwen2.5-Omni-7B \
--omni \
--port 8091 \
--worker-backend ray \
--ray-address auto
```
### 2.2 Cluster Setup
**Step 1: Start Head Node**
Run this on your primary machine:
```bash
ray start --head --port=6399
```
**Step 2: Connect Worker Nodes**
Run this on each worker machine:
```bash
ray start --address=<HEAD_NODE_IP>:6399
```
> **Tip**: For a complete cluster setup script, refer to the vLLM example:
> [run_cluster.sh](https://github.com/vllm-project/vllm/blob/main/examples/online_serving/run_cluster.sh)
### 2.3 Distributed Connector Support
When running on Ray, the system automatically adapts its communication strategy:
* **Cross-Node**: Recommended to use `MooncakeTransferEngineConnector` (RDMA, fastest) or `MooncakeStoreConnector` (TCP fallback).
* **Same-Node**: Can still use `SharedMemoryConnector` for efficiency, or Ray's native object store (plasma).
* **SHM payload routing**: `SharedMemoryConnector` always stores payloads in `/dev/shm`.
### 2.4 Internal Helpers
* **`initialize_ray_cluster`**: Connects to an existing Ray cluster or starts a local one.
## 3. Troubleshooting
* **Connection Issues**: Ensure the Ray head node is accessible and ports (default 6399 in this example) are open.
* **Version Mismatch**: Ensure all nodes run the same version of Ray and Python.
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+30 -12
View File
@@ -1,8 +1,9 @@
# Design Documents # Design Documents
This section contains design documents and architecture specifications for This section contains design documents and architecture specifications for
vLLM-Omni. The sidebar groups documents by the system concern they describe, vLLM-Omni. The sidebar groups documents by the system concern they describe;
while the source paths remain stable so existing links continue to work. retired module pages are preserved in the legacy archive instead of remaining
in the active navigation.
## Architecture Documents ## Architecture Documents
@@ -13,7 +14,6 @@ while the source paths remain stable so existing links continue to work.
### Runtime and stage execution ### Runtime and stage execution
- [Disaggregated Inference](feature/disaggregated_inference.md) - [Disaggregated Inference](feature/disaggregated_inference.md)
- [Ray-based Execution](feature/ray_based_execution.md)
- [Async Chunk](feature/async_chunk.md) - [Async Chunk](feature/async_chunk.md)
- [Async Diffusion Output](feature/async_diffusion_output.md) - [Async Diffusion Output](feature/async_diffusion_output.md)
- [Async Omni Output Materialization](feature/omni_async_output_materialization.md) - [Async Omni Output Materialization](feature/omni_async_output_materialization.md)
@@ -48,11 +48,12 @@ while the source paths remain stable so existing links continue to work.
#### Quantization #### Quantization
- [Quantization Overview](../user_guide/quantization/overview.md) - [Quantization](feature/quantization.md)
- [Cache-DiT](feature/cache_dit.md) - [Cache-DiT](feature/cache_dit.md)
- [TeaCache](feature/teacache.md) - [TeaCache](feature/teacache.md)
- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md) - [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md)
- [Distributed Layerwise Offload](feature/distributed_layerwise_offload.md)
## Infrastructure and Performance ## Infrastructure and Performance
@@ -61,12 +62,29 @@ while the source paths remain stable so existing links continue to work.
## Module Design Documents ## Module Design Documents
### Runtime Modules - [Entrypoints and Serving Boundaries](module/entrypoints.md)
- [vLLM-Omni Configuration](module/vllm_omni_config.md)
- [Input, Output, and Modality Contracts](module/input_output_modality_contracts.md)
- [Error Classification, Propagation, and Rendering](module/error_contracts.md)
- [Engine Orchestration](module/engine_orchestration.md)
- [Stage Runtime and Replica Lifecycle](module/stage_runtime.md)
- [OmniConnector](module/omni_connector.md)
- [Model Integration](module/model_integration.md)
- [Autoregressive Runtime](module/ar_runtime.md)
- Diffusion
- [Overview](module/diffusion/index.md)
- [Runtime](module/diffusion/diffusion_runtime.md)
- [Model Integration](module/diffusion/diffusion_model_integration.md)
- [Continuous Batching](module/diffusion/continuous_batching.md)
- [Parallelism](module/diffusion/parallelism.md)
- [Offloader](module/diffusion/offloader.md)
- [Execution Platforms](module/execution_platforms.md)
- [Cache Management](module/cache_management.md)
- [Quantization](module/quantization.md)
- [Observability](module/observability.md)
- [Profiling](module/profiling.md)
- [Benchmarking](module/benchmarking.md)
- [AR Module](module/ar_module.md) The pre-#5137 pages are preserved in the
- [DIT Module](module/dit_module.md) [legacy module archive](module/archive/README.md) for historical reference and
are not active design contracts.
### Orchestration
- [Entrypoint Module](module/entrypoint_module.md)
- [AsyncOmni Architecture (Qwen3-Omni Example)](module/async_omni_architecture.md)
+52
View File
@@ -0,0 +1,52 @@
---
title: Autoregressive Runtime
kind: module
status: draft
owners:
- "@tzhouam"
- "@yinpeiqi"
- "@fake0fan"
- "@Sy0307"
primary_code_paths:
- vllm_omni/core/**
- vllm_omni/worker/**
related_code_paths:
- vllm_omni/model_executor/**
depends_on:
- engine_orchestration.md
- model_integration.md
- input_output_modality_contracts.md
validation_paths:
- tests/core/**
- tests/worker/**
upstream_refs:
- vllm.v1.core
- vllm.v1.worker
last_reviewed: 2026-07-16
---
# Autoregressive runtime
The AR runtime extends vLLM scheduling and worker execution for omni-stage
inputs and outputs while preserving vLLM scheduling and cache semantics.
## Candidate invariants
### AR-INV-001: vLLM owns base scheduling semantics
**Rule:** Omni schedulers MUST preserve upstream request-state and cache
transitions unless an Omni-specific difference is documented and tested.
### AR-INV-002: Omni data crosses explicit adapters
**Rule:** Modality-specific stage data MUST be converted at an input or output
adapter, not injected through unrelated scheduler state.
### AR-INV-003: Workers execute assigned work
**Rule:** Workers and model runners MUST NOT implement cross-stage routing.
## Safe-change guide
Test request lifecycle, abort, cache state, and every affected worker execution
mode against the supported upstream vLLM contract.
+18
View File
@@ -0,0 +1,18 @@
# Archived module design pages
This directory preserves module-design pages that predate the structure
proposed in [issue #5137](https://github.com/vllm-project/vllm-omni/issues/5137).
They are historical snapshots, not active architectural contracts, and are
intentionally excluded from the documentation navigation.
| Archived page | Active destination |
| --- | --- |
| [AR Module](ar_module.md) | [Autoregressive Runtime](../ar_runtime.md) |
| [AsyncOmni Architecture](async_omni_architecture.md) | [Engine Orchestration](../engine_orchestration.md) and [Stage Runtime](../stage_runtime.md) |
| [DiT Module](dit_module.md) | [Diffusion module overview](../diffusion/index.md) |
| [Entrypoint Module](entrypoint_module.md) | [Entrypoints and Serving Boundaries](../entrypoints.md) |
Do not update these pages with current design guidance. Move still-useful
material into the active destination and review it against current code and
tests instead. No compatibility redirect pages are provided at the former
paths.
@@ -12,7 +12,7 @@ The AR module of vLLM-Omni extends vLLM's core components to support:
- **Hidden state exposure**: Exposing per-request hidden representations for downstream stages - **Hidden state exposure**: Exposing per-request hidden representations for downstream stages
- **Basic generator support**: Support some basic heterogeneous architecture such as Convolution, LSTM, etc. - **Basic generator support**: Support some basic heterogeneous architecture such as Convolution, LSTM, etc.
As shown in the [end2end example](../../user_guide/examples/offline_inference/qwen3_omni.md), AR module can be widely applied across multiple stages, generating text tokens in thinker(AR), audio latent tokens in talker(AR) and audio wave in code2wav(Convolution). As shown in the [end2end example](../../../user_guide/examples/offline_inference/qwen3_omni.md), AR module can be widely applied across multiple stages, generating text tokens in thinker(AR), audio latent tokens in talker(AR) and audio wave in code2wav(Convolution).
## 2. Relationship with vLLM ## 2. Relationship with vLLM
@@ -252,7 +252,7 @@ while True:
batch of compatible independent requests for one full pipeline forward when batch of compatible independent requests for one full pipeline forward when
the pipeline supports request-level batching; the step-wise path can also the pipeline supports request-level batching; the step-wise path can also
admit or remove compatible requests between denoise steps. See admit or remove compatible requests between denoise steps. See
[Diffusion Continuous Batching](../feature/diffusion_continuous_batching.md). [Diffusion Continuous Batching](../../feature/diffusion_continuous_batching.md).
--- ---
@@ -539,9 +539,9 @@ def get_attn_backend_for_role(role, head_size, attention_config=None, role_categ
4. **Platform default** — `current_omni_platform.get_diffusion_attn_backend_cls(...)` picks the best-available kernel for the hardware. 4. **Platform default** — `current_omni_platform.get_diffusion_attn_backend_cls(...)` picks the best-available kernel for the hardware.
A backend that needs configuration exposes it as a typed field on `AttentionSpec`; `AttentionSpec.backend_kwargs()` serializes it into the `backend_kwargs` the impl consumes. Today the only such field is `skip_softmax` (TRTLLM_ATTN), so e.g. `target_sparsity` can be set without changing model code. A backend that needs configuration exposes it as a typed field on `AttentionSpec`; `AttentionSpec.backend_kwargs()` serializes it into the `backend_kwargs` the impl consumes. Today those fields are `skip_softmax` (TRTLLM_ATTN) and `block_sparse` (any block-sparse backend, currently RAINFUSION_ATTN), so e.g. `target_sparsity` or `sparsity` can be set without changing model code.
For the user-facing CLI surface, see [Diffusion Attention Backends](../../user_guide/diffusion/attention_backends.md). For the role declaration contract on the model side, see [Adding a Diffusion Model](../../contributing/model/adding_diffusion_model.md). For the user-facing CLI surface, see [Diffusion Attention Backends](../../../user_guide/diffusion/attention_backends.md). For the role declaration contract on the model side, see [Adding a Diffusion Model](../../../contributing/model/adding_diffusion_model.md).
**Backend Availability**: **Backend Availability**:
+47
View File
@@ -0,0 +1,47 @@
---
title: Benchmarking
kind: module
status: draft
owners:
- "@alex-jw-brooks"
primary_code_paths:
- vllm_omni/benchmarks/**
- vllm_omni/entrypoints/cli/benchmark/**
related_code_paths:
- benchmarks/**
depends_on:
- entrypoints.md
- profiling.md
validation_paths:
- tests/benchmarks/**
- tests/dfx/perf/**
upstream_refs:
- vllm.benchmarks
last_reviewed: 2026-07-16
---
# Benchmarking
Benchmarking defines reproducible workloads, metrics, warm-up, measurement,
and result metadata for comparing vLLM-Omni performance.
## Candidate invariants
### BENCH-INV-001: Results are reproducible
**Rule:** Results MUST identify the model, revision, commit, hardware,
configuration, workload, concurrency, warm-up, and measurement window.
### BENCH-INV-002: Correctness precedes performance
**Rule:** A benchmark MUST validate successful and semantically acceptable
outputs before using timings as performance evidence.
### BENCH-INV-003: Metric definitions are explicit
**Rule:** Metrics MUST state their unit, population, and aggregation.
## Safe-change guide
Test workload parsing and metric calculations with deterministic fixtures. Do
not compare materially different workloads or runtime settings.
+47
View File
@@ -0,0 +1,47 @@
---
title: Cache Management
kind: module
status: draft
owners:
- "@Isotr0py"
- "@princepride"
primary_code_paths:
- vllm_omni/diffusion/cache/**
related_code_paths:
- vllm_omni/experimental/ar_diffusion/kv_cache/**
- vllm_omni/core/**
depends_on:
- ar_runtime.md
- diffusion/diffusion_runtime.md
validation_paths:
- tests/diffusion/cache/**
upstream_refs:
- vllm.v1.core.kv_cache_manager
last_reviewed: 2026-07-16
---
# Cache management
Cache management defines reusable state, cache identity, validity, lifecycle,
and eviction across AR and diffusion execution.
## Candidate invariants
### CACHE-INV-001: Cache identity is complete
**Rule:** A cache key MUST include every input and execution property that can
change the reused value.
### CACHE-INV-002: Reuse preserves correctness
**Rule:** A cache optimization MUST provide a disabled path and MUST NOT reuse
state after its validity conditions change.
### CACHE-INV-003: Ownership has an end
**Rule:** Request-scoped and model-scoped cache entries MUST have explicit
reset, eviction, or teardown behavior.
## Safe-change guide
Test hit, miss, invalidation, disabled, concurrent-request, and teardown paths.
@@ -0,0 +1,51 @@
---
title: Diffusion Continuous Batching
kind: module
status: draft
owners:
- "@Isotr0py"
- "@princepride"
primary_code_paths:
- vllm_omni/diffusion/sched/**
related_code_paths:
- vllm_omni/diffusion/executor/**
depends_on:
- diffusion_runtime.md
validation_paths:
- tests/diffusion/batching/**
upstream_refs: []
last_reviewed: 2026-07-16
---
# Diffusion continuous batching
Continuous batching defines when diffusion requests are compatible, how they
share an execution step, and how each request advances independently.
## Candidate invariants
### BATCH-INV-001: Compatibility is explicit
**Rule:** Requests MAY share a batch only when all properties required by the
selected pipeline, scheduler step, shape, precision, and execution features are
compatible.
### BATCH-INV-002: Per-request state remains isolated
**Rule:** Batch assembly MUST preserve each request's random generator, progress,
conditioning, outputs, cancellation, and errors.
### BATCH-INV-003: Admission is bounded
**Rule:** Admission MUST respect configured capacity and MUST NOT rely on an
unbounded waiting or active-request collection.
### BATCH-INV-004: Batch order is not completion order
**Rule:** Output association MUST use stable request identity rather than batch
position across execution steps.
## Safe-change guide
Test heterogeneous requests, partial completion, cancellation, deterministic
seeds, capacity limits, and batch sizes of one and many.
@@ -0,0 +1,58 @@
---
title: Diffusion Model Integration
kind: module
status: draft
owners:
- "@Isotr0py"
- "@wtomin"
- "@ZJY0516"
primary_code_paths:
- vllm_omni/diffusion/models/**
- vllm_omni/diffusion/model_loader/**
related_code_paths:
- vllm_omni/diffusion/layers/**
- vllm_omni/diffusion/lora/**
- vllm_omni/diffusion/utils/**
depends_on:
- diffusion_runtime.md
- ../input_output_modality_contracts.md
validation_paths:
- tests/diffusion/models/**
- tests/diffusion/model_loader/**
- tests/diffusion/layers/**
- tests/diffusion/lora/**
upstream_refs:
- diffusers.DiffusionPipeline
last_reviewed: 2026-07-16
---
# Diffusion model integration
Diffusion model integration owns pipeline contracts, registration, checkpoint
loading, adapters, shared layers, and model-specific processing.
## Candidate invariants
### DIFF-MODEL-INV-001: Pipelines implement one runtime contract
**Rule:** A pipeline MUST declare its supported modalities, configuration,
inputs, outputs, loading path, and runtime capabilities.
### DIFF-MODEL-INV-002: Registration is the selection boundary
**Rule:** Runtime code MUST select model implementations through the registry or
loader contract rather than scattered model-name conditionals.
### DIFF-MODEL-INV-003: Model code does not schedule requests
**Rule:** Pipeline code MUST NOT own admission, batching, cancellation, or
cross-stage routing.
### DIFF-MODEL-INV-004: Shared behavior stays shared
**Rule:** Model directories SHOULD contain only genuine model differences.
## Safe-change guide
Test registry selection, checkpoint loading, minimal inference, input and output
contracts, and every declared optional capability.
@@ -0,0 +1,58 @@
---
title: Diffusion Runtime
kind: module
status: draft
owners:
- "@Isotr0py"
- "@princepride"
- "@SamitHuang"
primary_code_paths:
- vllm_omni/diffusion/sched/**
- vllm_omni/diffusion/executor/**
- vllm_omni/diffusion/worker/**
related_code_paths:
- vllm_omni/diffusion/hooks/**
- vllm_omni/diffusion/postprocess/**
depends_on:
- ../engine_orchestration.md
- ../input_output_modality_contracts.md
- diffusion_model_integration.md
validation_paths:
- tests/diffusion/diffusion_backend/**
- tests/diffusion/hooks/**
upstream_refs:
- diffusers.DiffusionPipeline
last_reviewed: 2026-07-16
---
# Diffusion runtime
The diffusion runtime owns request admission, scheduling, execution, progress,
output, cancellation, and cleanup inside a diffusion stage.
## Candidate invariants
### DIFF-RUNTIME-INV-001: One lifecycle owner
**Rule:** Every admitted request MUST have exactly one scheduler-owned lifecycle
until completion, cancellation, or failure.
### DIFF-RUNTIME-INV-002: Execution follows scheduler output
**Rule:** Executors and workers MUST execute scheduler decisions without
admitting, reordering, or forwarding requests independently.
### DIFF-RUNTIME-INV-003: Terminal cleanup is complete
**Rule:** Every terminal path MUST release request state, temporary tensors,
hooks, and runtime-owned resources.
### DIFF-RUNTIME-INV-004: Optional features use runtime hooks
**Rule:** Cache, profiling, offload, and parallel features SHOULD integrate at
defined hooks instead of creating another request lifecycle.
## Safe-change guide
Test success, cancellation, failure, shutdown, multiple requests, and resource
cleanup.
+42
View File
@@ -0,0 +1,42 @@
---
title: Diffusion Modules
kind: module-index
status: draft
owners:
- "@Isotr0py"
- "@princepride"
- "@SamitHuang"
- "@wtomin"
- "@ZJY0516"
- "@RuixiangMa"
- "@david6666666"
- "@xuechendi"
primary_code_paths:
- vllm_omni/diffusion/**
related_code_paths:
- vllm_omni/platforms/**
depends_on:
- ../engine_orchestration.md
- ../input_output_modality_contracts.md
validation_paths:
- tests/diffusion/**
upstream_refs:
- diffusers
last_reviewed: 2026-07-16
---
# Diffusion modules
Diffusion is a module family containing runtime, model integration, batching,
distributed execution, and memory-management subsystems.
## Module documents
- [Diffusion runtime](diffusion_runtime.md)
- [Diffusion model integration](diffusion_model_integration.md)
- [Continuous batching](continuous_batching.md)
- [Parallelism](parallelism.md)
- [Offloader](offloader.md)
Cache, quantization, profiling, and benchmarking follow their top-level module
documents.
+53
View File
@@ -0,0 +1,53 @@
---
title: Diffusion Offloader
kind: module
status: draft
owners:
- "@yuanheng-zhao"
- "@Isotr0py"
primary_code_paths:
- vllm_omni/diffusion/offloader/**
related_code_paths:
- vllm_omni/diffusion/models/**
- vllm_omni/diffusion/worker/**
depends_on:
- diffusion_runtime.md
- ../execution_platforms.md
validation_paths:
- tests/diffusion/offloader/**
upstream_refs:
- torch.nn.Module.to
last_reviewed: 2026-07-16
---
# Diffusion offloader
The offloader owns component residency, transfer scheduling, memory accounting,
prefetch, and teardown for diffusion execution.
## Candidate invariants
### OFFLOAD-INV-001: Residency is explicit
**Rule:** Each managed component MUST have one known residency state and one
owner responsible for transitions.
### OFFLOAD-INV-002: Use follows readiness
**Rule:** Runtime execution MUST NOT consume a component until its transfer to
the required device is complete.
### OFFLOAD-INV-003: Transfers preserve model state
**Rule:** Offloading MUST preserve parameter and buffer identity, dtype, device,
and correctness required by the selected execution mode.
### OFFLOAD-INV-004: Memory is bounded and released
**Rule:** Retained host and device copies MUST respect configured limits and
provide deterministic teardown.
## Safe-change guide
Test enabled and disabled paths, repeated execution, asynchronous transfer,
memory limits, failure, teardown, and numerical equivalence.
@@ -0,0 +1,56 @@
---
title: Diffusion Parallelism
kind: module
status: draft
owners:
- "@princepride"
- "@yuanheng-zhao"
- "@xuechendi"
primary_code_paths:
- vllm_omni/diffusion/distributed/**
- vllm_omni/diffusion/attention/parallel/**
related_code_paths:
- vllm_omni/distributed/**
- vllm_omni/config/composable_parallel/**
depends_on:
- diffusion_runtime.md
- ../vllm_omni_config.md
validation_paths:
- tests/diffusion/distributed/**
- tests/diffusion/attention/**
upstream_refs:
- torch.distributed
last_reviewed: 2026-07-16
---
# Diffusion parallelism
Parallelism owns diffusion rank topology, process groups, tensor and sequence
sharding, collectives, and distributed execution.
## Candidate invariants
### PARALLEL-INV-001: Topology has one source of truth
**Rule:** Rank coordinates, group membership, and parallel dimensions MUST be
derived from validated configuration.
### PARALLEL-INV-002: Sharding contracts are explicit
**Rule:** Every distributed boundary MUST define tensor shape, sharded
dimension, placement, dtype, producer, and consumer.
### PARALLEL-INV-003: Collectives are symmetric
**Rule:** Process-group members MUST invoke compatible collectives in the same
logical order.
### PARALLEL-INV-004: Single-rank behavior remains valid
**Rule:** Distributed implementations SHOULD preserve a supported single-rank
path without distributed initialization.
## Safe-change guide
Test topology validation, single-rank execution, affected parallel modes,
combined modes, invalid shapes, and orderly teardown.
+122
View File
@@ -0,0 +1,122 @@
---
title: Engine Orchestration
kind: module
status: draft
architecture_state: current-plus-in-flight
owners:
- "@tzhouam"
- "@fake0fan"
document_stewards:
- "@hsliuustc0106"
- "@Gaohan123"
- "@david6666666"
required_reviewers:
- "@yinpeiqi"
- "@Sy0307"
- "@yenuo26"
- "@NickCao"
primary_code_paths:
- vllm_omni/engine/async_omni_engine.py
- vllm_omni/engine/async_engine_utils.py
- vllm_omni/engine/orchestrator.py
- vllm_omni/engine/cfg_companion_tracker.py
- vllm_omni/engine/rpc_result_router.py
related_code_paths:
- vllm_omni/engine/messages.py
- vllm_omni/engine/orchestrator_monitor.py
- vllm_omni/engine/stage_client.py
- vllm_omni/engine/stage_pool.py
- vllm_omni/outputs/output_processor.py
- vllm_omni/distributed/omni_coordinator/**
depends_on:
- input_output_modality_contracts.md
- error_contracts.md
- stage_runtime.md
- omni_connector.md
validation_paths:
- tests/engine/test_async_omni_engine_input.py
- tests/engine/test_async_omni_engine_outputs.py
- tests/engine/test_async_omni_engine_abort.py
- tests/engine/test_async_omni_engine_stage_init.py
- tests/engine/test_orchestrator.py
- tests/engine/test_orchestrator_error_handling.py
- tests/engine/test_orchestrator_stage_input_bridge.py
- tests/engine/test_cfg_companion_lifecycle.py
- tests/engine/test_rpc_result_router.py
- tests/e2e/features/fullduplex/engine/**
upstream_refs:
- vllm.v1.engine.EngineCoreRequest
- vllm.v1.engine.EngineCoreOutputs
- vllm.v1.engine.exceptions.EngineDeadError
- vllm.outputs.RequestOutput
invariant_namespace: ORCH-INV
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# Engine orchestration
Engine orchestration coordinates configured AR and diffusion stages while
keeping public clients independent from stage processes and transports.
## Contract status
This document is a draft description of current behavior. It also identifies
the boundary affected by the in-flight stage client/process refactor in
[#5441](https://github.com/vllm-project/vllm-omni/pull/5441). Names and
responsibilities proposed only by that PR are not current contracts.
## Ownership boundary
This document owns `AsyncOmniEngine`, `Orchestrator`, request-state creation,
cross-stage routing, output ordering, companion tracking, control/RPC
correlation, cancellation propagation, and terminal-state convergence.
It does not own stage placement, replica selection, stage-process startup,
payload schemas, public protocol rendering, connector transport, or semantic
error classification. Those responsibilities belong to the stage runtime,
I/O, entrypoint, connector, and error contracts.
## Candidate invariants
These identifiers are proposals while the document is `draft`.
### ORCH-INV-001: The orchestrator owns cross-stage routing
**Rule:** Entrypoints and stage clients MUST NOT independently forward a
request to a downstream logical stage.
### ORCH-INV-002: Stage clients do not own routing policy
**Rule:** Stage clients MUST implement communication and lifecycle operations
without selecting the next logical stage.
### ORCH-INV-100: Terminal state is monotonic
**Rule:** Once a request reaches a terminal state, orchestration MUST NOT
forward new work for that request.
## Invariant namespace
`ORCH-INV` reserves `001-099` for dependency direction, `100-199` for
request state and ordering, `200-299` for failure/cancellation/cleanup, and
`300-399` for extension points, shutdown ordering, and upstream alignment.
Numbers become append-only after normative promotion.
## Safe-change guide
Test routing, output ordering, queue correlation, cancellation, failure
propagation, shutdown ordering, and representative multi-stage execution.
Changes that cross into stage runtime or public error behavior require review
from that contract's owners.
## Promotion gate
- Reconcile current names and boundaries after #5441 merges, closes, or is
replaced.
- Demonstrate one terminal outcome per request across success, abort,
request-scoped failure, and fatal engine failure.
- Verify queue correlation and orchestrator-before-runtime shutdown ordering
in the cited validation paths.
- Obtain approval from a technical owner and an independent validation
reviewer.
+134
View File
@@ -0,0 +1,134 @@
---
title: Entrypoints and Serving Boundaries
kind: module
status: draft
architecture_state: current-plus-in-flight
owners:
- "@alex-jw-brooks"
- "@linyueqian"
document_stewards:
- "@hsliuustc0106"
- "@Gaohan123"
- "@david6666666"
required_reviewers:
- "@tzhouam"
- "@herotai214"
- "@yenuo26"
- "@NickCao"
primary_code_paths:
- vllm_omni/entrypoints/omni.py
- vllm_omni/entrypoints/async_omni.py
- vllm_omni/entrypoints/omni_base.py
- vllm_omni/entrypoints/cli/**
- vllm_omni/entrypoints/openai/**
- vllm_omni/entrypoints/openpi/**
- vllm_omni/entrypoints/client_request_state.py
- vllm_omni/entrypoints/stage_utils.py
- vllm_omni/entrypoints/utils.py
primary_path_exceptions:
- path: vllm_omni/entrypoints/openai/errors.py
owner: error_contracts.md
related_code_paths:
- vllm_omni/errors.py
- vllm_omni/inputs/**
- vllm_omni/outputs/**
- vllm_omni/engine/async_omni_engine.py
- vllm_omni/config/**
- vllm_omni/deploy/**
depends_on:
- input_output_modality_contracts.md
- error_contracts.md
- engine_orchestration.md
validation_paths:
- tests/entrypoints/test_omni_entrypoints.py
- tests/entrypoints/test_async_omni.py
- tests/entrypoints/test_async_omni_duplex.py
- tests/entrypoints/test_serve.py
- tests/entrypoints/test_stream_finish_reason.py
- tests/entrypoints/openai/**
- tests/entrypoints/openai_api/**
- tests/e2e/online_serving/**
upstream_refs:
- vllm.engine.protocol.EngineClient
- vllm.entrypoints.openai/**
- vllm.entrypoints.serve/**
- vllm.renderers/**
- vllm.v1.engine.exceptions/**
invariant_namespace: ENTRY-INV
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# Entrypoints and serving boundaries
Entrypoints translate offline, CLI, and serving requests into stable engine
operations and translate engine outputs into public responses.
## Contract status
This document describes current entrypoint responsibilities plus the boundary
under review in the open roadmap
[#5227](https://github.com/vllm-project/vllm-omni/issues/5227) and helper-move
PR [#5453](https://github.com/vllm-project/vllm-omni/pull/5453). In-flight
helper locations are not treated as current paths.
## Ownership boundary
This document owns offline API semantics, CLI and serve composition,
supported OpenAI-compatible routes, request validation and normalization,
response conversion, streaming/session behavior, and engine handoff.
It does not own configuration precedence, cross-stage routing, stage
placement, payload implementation, or semantic error classification.
`entrypoints/openai/errors.py` is an explicit primary-path exception owned by
`error_contracts.md`.
## Candidate invariants
These identifiers are proposals while the document is `draft`.
### ENTRY-INV-001: Entrypoints adapt but do not orchestrate
**Rule:** Entrypoints MUST NOT implement cross-stage routing or stage lifecycle
policy.
### ENTRY-INV-100: Public requests are normalized once
**Rule:** Public protocol values MUST be validated and converted to internal
request contracts before engine submission.
### ENTRY-INV-101: Streaming preserves request identity
**Rule:** Every streamed response MUST remain associated with the request and
output modality that produced it.
The reviewer-proposed rule that model-specific behavior should stay behind a
common adapter or processor abstraction remains an unnumbered candidate until
the entrypoint refactor makes that boundary enforceable.
## Invariant namespace
`ENTRY-INV` reserves `001-099` for boundary and dependency direction,
`100-199` for normalization, sessions, and streaming, `200-299` for
disconnect, cancellation, rendering, and cleanup, and `300-399` for upstream
route and renderer compatibility. Numbers become append-only after normative
promotion.
## Safe-change guide
Test request validation, protocol conversion, model-adapter routing,
streaming, session identity, disconnect/cancellation, and error mapping for
each affected offline or serving entrypoint.
## Promotion gate
- Reconcile the page after #5227's P0 ownership work and #5453's final
disposition.
- Publish a supported route/transport matrix rather than claiming blanket
OpenAI parity.
- Verify one normalization and handoff path per route family and one terminal
outcome per streaming transport.
- Promote the `api_server.py` composition-root rule only after helper moves and
import boundaries enforce it.
- Obtain approval from a technical owner and an independent validation
reviewer.
+109
View File
@@ -0,0 +1,109 @@
---
title: Error Classification, Propagation, and Rendering
kind: module
status: draft
architecture_state: current-plus-rfc
owners:
- "@alex-jw-brooks"
- "@NickCao"
document_stewards:
- "@hsliuustc0106"
- "@Gaohan123"
- "@david6666666"
required_reviewers:
- "@yinpeiqi"
- "@tzhouam"
- "@yenuo26"
primary_code_paths:
- vllm_omni/errors.py
- vllm_omni/entrypoints/openai/errors.py
related_code_paths:
- vllm_omni/engine/messages.py
- vllm_omni/engine/orchestrator.py
- vllm_omni/entrypoints/omni_base.py
- vllm_omni/entrypoints/async_omni.py
- vllm_omni/entrypoints/openai/api_server.py
- vllm_omni/entrypoints/openai/serving_chat.py
- vllm_omni/entrypoints/openai/serving_speech.py
- vllm_omni/entrypoints/openai/serving_audio_generate.py
- vllm_omni/entrypoints/openai/protocol/images.py
- vllm_omni/entrypoints/openai/protocol/videos.py
depends_on: []
validation_paths:
- tests/engine/test_orchestrator_error_handling.py
- tests/engine/test_async_omni_engine_outputs.py
- tests/entrypoints/test_omni_entrypoints.py
- tests/entrypoints/test_stream_finish_reason.py
- tests/entrypoints/openai_api/test_image_server.py
- tests/entrypoints/openai_api/test_video_server.py
- tests/entrypoints/openai_api/test_serving_audio_generate.py
- tests/entrypoints/openai_api/test_serving_speech.py
- tests/metrics/test_prometheus.py
upstream_refs:
- vllm.v1.engine.exceptions.EngineDeadError
- vllm.v1.engine.exceptions.EngineGenerateError
- vllm.entrypoints.serve.utils.error_response
- vLLM RFC #48227
- vLLM PR #49665
invariant_namespace: ERR-INV
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# Error classification, propagation, and rendering
This contract separates the meaning of a failure from the transport used to
carry it and the public protocol used to render it.
## Contract status
Current behavior is distributed across engine, message, and entrypoint code.
The semantic hierarchy proposed by
[#5570](https://github.com/vllm-project/vllm-omni/issues/5570) remains an open
RFC. Proposed types and wire records from that RFC are not current contracts.
## Ownership boundary
This document owns semantic classification, fatal versus request-scoped
failure, cross-process error context, compatibility policy, sanitization, and
offline, HTTP, SSE, WebSocket, and asynchronous-job rendering rules.
`engine/messages.py` remains primarily owned by the I/O contract; this page
owns the semantics of its error fields. Route and serving files remain
primarily owned by `entrypoints.md`; this page owns their rendering
constraints.
## Candidate invariants
The following topics require owner review before receiving stable IDs:
- A request-scoped failure terminates only the affected request unless the
underlying engine is no longer usable.
- Every public rendering path preserves a stable classification while
removing sensitive internal context.
- Cancellation remains distinguishable from internal failure across process
and protocol boundaries.
## Invariant namespace
`ERR-INV` reserves `001-099` for classification ownership, `100-199` for wire
context and compatibility, `200-299` for fatality, termination, sanitization,
and rendering, and `300-399` for upstream alignment and transport extension
points. Numbers become append-only after normative promotion.
## Safe-change guide
Exercise exception classification, stage wire round trips, fatal and
request-scoped propagation, cancellation, sanitization, offline exceptions,
HTTP status/body consistency, streaming terminal events, asynchronous-job
persistence, and metrics classification.
## Promotion gate
- Resolve #5570's hierarchy, wire record, sanitization, and mixed-version
questions.
- Add table-driven exception/status/type tests and stage wire round trips.
- Verify all public transports terminate once and do not leak sensitive
payload data.
- Obtain approval from both technical owners and an independent validation
reviewer.
+48
View File
@@ -0,0 +1,48 @@
---
title: Execution Platforms
kind: module
status: draft
owners:
- "@gcanlin"
- "@tjtanaa"
- "@xuechendi"
primary_code_paths:
- vllm_omni/platforms/**
related_code_paths:
- vllm_omni/attention/**
depends_on:
- ar_runtime.md
- diffusion/index.md
validation_paths:
- tests/platforms/**
upstream_refs:
- vllm.platforms
last_reviewed: 2026-07-16
---
# Execution platforms
Execution platforms isolate hardware-specific capability detection, worker
selection, patches, kernels, and configuration adjustments.
## Candidate invariants
### PLATFORM-INV-001: Capabilities are explicit
**Rule:** Hardware-dependent behavior MUST be guarded by platform selection or
capability detection and MUST fail clearly when unsupported.
### PLATFORM-INV-002: Portable code remains portable
**Rule:** Platform-neutral modules MUST NOT import a vendor implementation
directly when a common selection interface exists.
### PLATFORM-INV-003: Overrides are minimal
**Rule:** A platform implementation SHOULD override only behavior that differs
from the common or upstream implementation.
## Safe-change guide
Validate common import paths without the target accelerator and run focused
tests in a fresh `uv` environment on every affected device platform.
@@ -0,0 +1,133 @@
---
title: Input, Output, and Modality Contracts
kind: module
status: draft
architecture_state: current
owners:
- "@Sy0307"
- "@amy-why-3459"
document_stewards:
- "@hsliuustc0106"
- "@Gaohan123"
- "@david6666666"
required_reviewers:
- "@tzhouam"
- "@linyueqian"
- "@david6666666"
- "@yenuo26"
- "@NickCao"
primary_code_paths:
- vllm_omni/inputs/**
- vllm_omni/outputs/**
- vllm_omni/request.py
- vllm_omni/data_entry_keys.py
- vllm_omni/engine/messages.py
- vllm_omni/engine/serialization.py
- vllm_omni/engine/mm_outputs.py
- vllm_omni/engine/output_modality.py
- vllm_omni/engine/output_processor.py
related_code_paths:
- vllm_omni/model_executor/stage_input_processors/**
- vllm_omni/core/sched/**
- vllm_omni/distributed/omni_connectors/**
- vllm_omni/diffusion/request.py
- vllm_omni/diffusion/worker/request_batch.py
- vllm_omni/errors.py
depends_on:
- error_contracts.md
validation_paths:
- tests/inputs/**
- tests/engine/test_data_entry_keys.py
- tests/engine/test_omni_request.py
- tests/engine/test_omni_request_output.py
- tests/engine/test_multimodal_accumulation.py
- tests/engine/test_output_modality.py
- tests/engine/test_output_processor.py
- tests/engine/test_wire_multimodal_output.py
- tests/utils/test_mm_outputs.py
- tests/utils/test_mm_outputs_partition.py
- tests/diffusion/test_diffusion_output_metadata.py
upstream_refs:
- vllm.inputs/**
- vllm.outputs/**
- vllm.v1.request.Request
- vllm.v1.engine.EngineCoreRequest
- vllm.v1.engine.EngineCoreOutput
- vllm.v1.engine.output_processor.OutputProcessor
invariant_namespace: IO-INV
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# Input, output, and modality contracts
These contracts define the data that may cross entrypoint, orchestration,
stage, connector, and model boundaries.
## Contract status
This document is a draft description of the current request, message,
serialization, modality, accumulation, and output types. It does not assign
semantic error ownership to the wire schema.
## Ownership boundary
This document owns request identity, prompt/input types, modality keys and
metadata, queue and wire message schemas, serialization, output types,
accumulation, completion semantics, and compatibility shims.
It does not own route-specific validation or rendering, model-specific
interpretation, connector transport mechanics, scheduling policy, or semantic
error classification. The `ErrorMessage` schema belongs here; the meaning and
public rendering of its error fields belong to `error_contracts.md`.
## Candidate invariants
These identifiers are proposals while the document is `draft`.
### IO-INV-001: Boundary data has an explicit modality
**Rule:** Data crossing a module or stage boundary MUST identify its modality
and use the corresponding validated contract.
### IO-INV-100: Request identity is stable
**Rule:** Request identity MUST be preserved across conversions, stages,
streaming updates, cancellation, and errors.
### IO-INV-101: Completion is explicit
**Rule:** Producers MUST distinguish partial updates from the terminal update
for every output modality.
### IO-INV-300: Internal objects do not leak into public protocols
**Rule:** Entrypoints MUST explicitly translate internal output objects into
public response types.
## Invariant namespace
`IO-INV` reserves `001-099` for schema ownership and dependency direction,
`100-199` for identity, modality, accumulation, ordering, and completion,
`200-299` for serialization failure and payload cleanup, and `300-399` for
upstream extension, deprecation, and compatibility. Numbers become
append-only after normative promotion.
## Safe-change guide
Test construction, validation, serialization, round trips, streaming,
accumulation, completion, optional fields, unknown fields, and compatibility
at every affected producer-consumer boundary.
## Promotion gate
- Add explicit CODEOWNERS for the primary I/O paths or confirm that fallback
ownership is intentional.
- Document the compatibility window for deprecated `vllm_omni.engine`
I/O-related imports.
- Verify wire round trips and rejection of unknown or incompatible schema
fields.
- Define payload-versus-metadata rules and completion semantics for streaming
and batched outputs.
- Obtain approval from a technical owner and independent AR, diffusion,
connector, entrypoint, and validation reviewers as applicable.
+51
View File
@@ -0,0 +1,51 @@
---
title: Model Integration
kind: module
status: draft
owners:
- "@tzhouam"
- "@gcanlin"
primary_code_paths:
- vllm_omni/model_executor/**
- vllm_omni/model_extras/**
- vllm_omni/plugins/**
related_code_paths:
- vllm_omni/transformers_utils/**
- vllm_omni/tokenizers/**
depends_on:
- input_output_modality_contracts.md
- ar_runtime.md
validation_paths:
- tests/model_executor/**
- tests/model_extras/**
- tests/plugin/**
upstream_refs:
- vllm.model_executor
last_reviewed: 2026-07-16
---
# Model integration
Model integration adapts model-specific configuration, preprocessing, loading,
and execution behavior to stable runtime contracts.
## Candidate invariants
### MODEL-INV-001: Registration is explicit
**Rule:** A model integration MUST declare how its model class, loader, input
processor, and stage configuration are selected.
### MODEL-INV-002: Model code does not route stages
**Rule:** Model-specific code MUST NOT select or invoke a downstream omni stage.
### MODEL-INV-003: Upstream behavior is reused deliberately
**Rule:** An upstream vLLM implementation SHOULD be reused when its contract is
sufficient; an override MUST document the behavioral difference.
## Safe-change guide
Test registration, checkpoint loading, input conversion, and representative
model execution. Test shared utilities against more than one integration.
+46
View File
@@ -0,0 +1,46 @@
---
title: Observability
kind: module
status: draft
owners:
- "@lishunyang12"
primary_code_paths:
- vllm_omni/metrics/**
related_code_paths:
- vllm_omni/engine/**
- vllm_omni/entrypoints/**
depends_on:
- engine_orchestration.md
validation_paths:
- tests/metrics/**
upstream_refs:
- vllm.v1.metrics
last_reviewed: 2026-07-16
---
# Observability
Observability defines stable metrics, logs, and correlation fields for tracing a
request across entrypoints, orchestration, stages, and workers.
## Candidate invariants
### OBS-INV-001: Request correlation survives stage boundaries
**Rule:** Logs and metrics for the same request SHOULD carry a stable request
identifier and stage identity.
### OBS-INV-002: Metric meaning is stable
**Rule:** A metric's unit, labels, aggregation, and lifecycle MUST be documented;
changing any of them is a contract change.
### OBS-INV-003: Labels are bounded
**Rule:** Metrics MUST NOT use request IDs, prompts, outputs, or other unbounded
values as labels.
## Safe-change guide
Test registration, label sets, units, lifecycle resets, and representative
multi-stage emission.
+50
View File
@@ -0,0 +1,50 @@
---
title: OmniConnector
kind: module
status: draft
owners:
- "@princepride"
- "@yuanheng-zhao"
- "@xuechendi"
- "@natureofnature"
- "@fake0fan"
primary_code_paths:
- vllm_omni/distributed/omni_connectors/**
related_code_paths:
- vllm_omni/platforms/*/omni_connectors/**
depends_on:
- input_output_modality_contracts.md
- vllm_omni_config.md
validation_paths:
- tests/distributed/omni_connectors/**
upstream_refs:
- vllm.distributed
last_reviewed: 2026-07-16
---
# OmniConnector
OmniConnector defines model-agnostic transport and synchronization contracts
for data exchanged across stages, processes, devices, and nodes.
## Candidate invariants
### CONNECTOR-INV-001: Connectors transport but do not route
**Rule:** A connector MUST NOT select the next stage or implement model-specific
execution policy.
### CONNECTOR-INV-002: Producer and consumer contracts agree
**Rule:** Both ends MUST agree on data identity, shape, dtype, placement,
ownership, and completion semantics.
### CONNECTOR-INV-003: Resources have deterministic cleanup
**Rule:** Connections, buffers, handles, and background work MUST be released on
normal completion, cancellation, failure, and shutdown.
## Safe-change guide
Test setup, transfer, synchronization, timeout, cancellation, failure, and
cleanup across every affected backend.
+46
View File
@@ -0,0 +1,46 @@
---
title: Profiling
kind: module
status: draft
owners:
- "@gcanlin"
primary_code_paths:
- vllm_omni/profiler/**
- vllm_omni/diffusion/profiler/**
related_code_paths:
- vllm_omni/entrypoints/**
depends_on:
- observability.md
validation_paths:
- tests/profile/**
upstream_refs:
- vllm.profiler
last_reviewed: 2026-07-16
---
# Profiling
Profiling owns opt-in instrumentation and trace collection used to locate
runtime bottlenecks without changing model results or execution policy.
## Candidate invariants
### PROFILE-INV-001: Profiling is opt-in
**Rule:** Expensive trace collection MUST be disabled by default and bounded by
an explicit start and stop lifecycle.
### PROFILE-INV-002: Instrumentation preserves semantics
**Rule:** Profiling MUST NOT change scheduling decisions, generated outputs, or
resource ownership beyond documented synchronization overhead.
### PROFILE-INV-003: Trace context is sufficient
**Rule:** Trace events SHOULD identify stage, rank, worker, operation, and
request where available without embedding user payloads.
## Safe-change guide
Test disabled overhead, repeated start and stop, cleanup, multi-rank trace
naming, and each supported profiler backend.
+50
View File
@@ -0,0 +1,50 @@
---
title: Quantization
kind: module
status: draft
owners:
- "@david6666666"
- "@Isotr0py"
- "@lishunyang12"
primary_code_paths:
- vllm_omni/quantization/**
- vllm_omni/diffusion/quantization/**
related_code_paths:
- vllm_omni/platforms/*/quant/**
depends_on:
- model_integration.md
- diffusion/diffusion_model_integration.md
validation_paths:
- tests/quantization/**
- tests/diffusion/quantization/**
upstream_refs:
- vllm.model_executor.layers.quantization
last_reviewed: 2026-07-16
---
# Quantization
Quantization owns method registration, checkpoint interpretation, layer
replacement, and runtime constraints for reduced-precision execution.
## Candidate invariants
### QUANT-INV-001: Checkpoint and runtime methods agree
**Rule:** The selected method MUST validate checkpoint metadata, supported
layers, device capabilities, and required dependencies before use.
### QUANT-INV-002: Unsupported combinations fail clearly
**Rule:** Quantization MUST NOT silently fall back to a different precision or
method when a requested combination is unsupported.
### QUANT-INV-003: Numerical validation is required
**Rule:** A new method or layer mapping MUST include correctness comparison
against a defined reference result.
## Safe-change guide
Test loading, representative layers, metadata, platform guards, and numerical
tolerances separately from performance.
+126
View File
@@ -0,0 +1,126 @@
---
title: Stage Runtime and Replica Lifecycle
kind: module
status: draft
architecture_state: current-plus-in-flight
owners:
- "@tzhouam"
- "@fake0fan"
document_stewards:
- "@hsliuustc0106"
- "@Gaohan123"
- "@david6666666"
required_reviewers:
- "@Sy0307"
- "@princepride"
- "@chickeyton"
- "@yenuo26"
- "@NickCao"
primary_code_paths:
- vllm_omni/engine/stage_runtime.py
- vllm_omni/engine/stage_pool.py
- vllm_omni/engine/stage_client.py
- vllm_omni/engine/membership_controller.py
- vllm_omni/engine/stage_engine_startup.py
- vllm_omni/engine/stage_engine_core_client.py
- vllm_omni/engine/stage_engine_core_proc.py
- vllm_omni/engine/stage_engine_core_proc_manager.py
- vllm_omni/engine/stage_init_utils.py
related_code_paths:
- vllm_omni/distributed/omni_coordinator/**
- vllm_omni/distributed/ray_utils/**
- vllm_omni/diffusion/inline_stage_diffusion_client.py
- vllm_omni/diffusion/stage_diffusion_client.py
- vllm_omni/diffusion/stage_diffusion_proc.py
- vllm_omni/config/**
depends_on:
- input_output_modality_contracts.md
- error_contracts.md
- omni_connector.md
validation_paths:
- tests/engine/test_async_omni_engine_stage_init.py
- tests/engine/test_membership_controller.py
- tests/engine/test_single_stage_mode.py
- tests/engine/test_stage_engine_core_client.py
- tests/engine/test_stage_engine_core_proc.py
- tests/diffusion/test_inline_stage_diffusion_client.py
- tests/diffusion/test_stage_diffusion_proc.py
- tests/distributed/omni_coordinator/**
upstream_refs:
- vllm.v1.engine.core_client.AsyncMPClient
- vllm.v1.engine.core_client.DPLBAsyncMPClient
- vllm.v1.engine.core.EngineCoreProc
- vllm.v1.engine.utils.CoreEngineProcManager
- vllm.v1.engine.coordinator.DPCoordinator
invariant_namespace: STAGE-INV
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# Stage runtime and replica lifecycle
The stage runtime turns a logical stage definition into ready local or
distributed replicas and provides the lifecycle boundary used by the
orchestrator.
## Contract status
This document describes the current `StageRuntime`, `StagePool`, stage-client,
and stage-process layout. The unified LLM/diffusion direction in
[#5441](https://github.com/vllm-project/vllm-omni/pull/5441) remains in flight
and is not presented as current behavior.
## Ownership boundary
This document owns local and distributed stage placement, startup and
readiness, one pool per logical stage, replica identity and membership,
selection and affinity, stage-client/process lifecycle, liveness, draining,
and shutdown.
It does not own cross-stage request policy, model scheduler policy, connector
transfer semantics, configuration precedence, or public error rendering.
## Candidate invariants
These identifiers are proposals while the document is `draft`.
### STAGE-INV-001: The runtime owns replica lifecycle
**Rule:** Orchestration MUST acquire stage capacity through the stage runtime
and MUST NOT construct or retire backend replicas directly.
### STAGE-INV-100: Affinity uses stable replica identity
**Rule:** Follow-up operations for a request with replica affinity MUST resolve
the same logical replica until the request terminates or the replica is
declared lost.
### STAGE-INV-200: Shutdown is idempotent
**Rule:** Repeated shutdown or cleanup signals MUST NOT leak stage clients,
process managers, or membership registrations.
## Invariant namespace
`STAGE-INV` reserves `001-099` for placement and ownership, `100-199` for
replica identity/readiness/affinity, `200-299` for liveness loss, draining and
cleanup, and `300-399` for local/distributed and upstream compatibility.
Numbers become append-only after normative promotion.
## Safe-change guide
Exercise local and distributed startup, readiness, replica selection,
affinity, membership changes, abort routing, process failure, draining, and
repeated shutdown. Cross-stage policy changes belong in
`engine_orchestration.md`.
## Promotion gate
- Reconcile terminology and ownership after #5441 reaches a final state.
- Add or identify a dedicated StageRuntime/StagePool lifecycle suite; current
evidence is distributed across the cited tests.
- Demonstrate affinity preservation for update, interaction, and abort paths.
- Demonstrate membership loss and repeated shutdown without leaked clients or
processes.
- Obtain approval from a technical owner and an independent validation
reviewer.
+56
View File
@@ -0,0 +1,56 @@
---
title: vLLM-Omni Configuration
kind: module
status: draft
architecture_state: deferred-pending-refactor
ownership_status: provisional
owners:
- "@lishunyang12"
- "@alex-jw-brooks"
primary_code_paths:
- vllm_omni/config/**
- vllm_omni/deploy/**
- vllm_omni/model_executor/stage_configs/**
related_code_paths:
- vllm_omni/platforms/*/stage_configs/**
depends_on: []
validation_paths:
- tests/config/**
upstream_refs:
- vllm.config
last_reviewed: 2026-08-07
last_verified_commit: 3d7fc3b9ba3cac88d579d4dc35b78b0b641675fc
---
# vLLM-Omni configuration
This page intentionally records discovery paths only. Substantive
configuration ownership and contract work is deferred until the active
configuration refactoring has settled.
## Deferred contract scope
The current code and tests remain authoritative for parsing, defaults,
overrides, deployment topology, stage construction, and runtime projection.
This draft does not define a stable precedence model, environment-variable
capture rule, canonical configuration object, or invariant namespace.
The PR review proposal to capture environment-derived values in configuration
objects at initialization is an open design question, not a current
invariant.
## Safe-change guide while deferred
Changes should trace every affected producer to its runtime consumer and test
the structured, legacy, CLI, and deployment paths that are actually supported.
Do not infer a stable contract from this placeholder page.
## Promotion gate
- Finish or stabilize the configuration refactor and identify the canonical
runtime configuration representation.
- Confirm technical owners, primary path exceptions, and validation owners.
- Document parsing and override precedence, including when environment values
are captured.
- Allocate an invariant namespace only after those behaviors are enforced and
owner-approved.
@@ -14,7 +14,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| Value | Notes | | Value | Notes |
|---|---| |---|---|
| `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Supports **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. | | `TRTLLM_ATTN` | FlashInfer's trtllm-gen FMHA (TensorRT-LLM's generated kernels, vendored by FlashInfer). Dense BF16, GQA native, `head_dim=128`. Datacenter Blackwell only (sm_100 / sm_103). Packed paths can provide `cu_seqlens` directly. Supports optional **Skip-Softmax** sparse attention — see below. Requires `flashinfer`. |
| `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. | | `FLASH_ATTN` | Wraps FlashAttention 4 on Blackwell when `flash-attn-4` is installed, then falls back to FlashAttention 3/2. Default on Hopper / Ada / Ampere when a compatible FlashAttention package is installed. |
| `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). | | `CUDNN_ATTN` | Pins `sdpa_kernel([CUDNN_ATTENTION])`. Default on Blackwell (sm_10x / sm_12x) with cuDNN ≥ 9.5. Wins on mask-heavy DiTs (HunyuanVideo-1.5: 2× e2e vs SDPA). |
| `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. | | `FLASHINFER_ATTN` | Calls FlashInfer's dense `single_prefill_with_kv_cache` directly with `custom_mask` for non-causal masked attention. Used as Blackwell fallback when cuDNN is unavailable. Requires `flashinfer`. |
@@ -23,6 +23,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. | | `SAGE_ATTN_3` | Requires `sageattn3` from `SageAttention/sageattention3_blackwell`. CUDA only, intended for Blackwell GPUs, with GQA/MQA requests falling back to PyTorch SDPA. |
| `FLASH_ATTN_HUB` | FlashAttention 2 from HuggingFace `kernels` library. Useful for train/rollout alignment. | | `FLASH_ATTN_HUB` | FlashAttention 2 from HuggingFace `kernels` library. Useful for train/rollout alignment. |
| `FLASH_ATTN_3_HUB` | FlashAttention 3 from HuggingFace `kernels` library. CUDA Hopper (sm_90+) only; falls back to `FLASH_ATTN_HUB` on older GPUs. | | `FLASH_ATTN_3_HUB` | FlashAttention 3 from HuggingFace `kernels` library. CUDA Hopper (sm_90+) only; falls back to `FLASH_ATTN_HUB` on older GPUs. |
| `RAINFUSION_ATTN` | MindIE-SD **RainFusion** block-sparse video attention — see [below](#rainfusion_attn-backend-and-block-sparse-video-attention). Ascend NPU only; requires `mindiesd`. Delegates to `FLASH_ATTN` for anything that is not a packed video sequence. |
## Configuration ## Configuration
@@ -69,8 +70,9 @@ vllm-omni serve <model> \
--diffusion-attention-config '{"default":{"backend":"FLASH_ATTN"},"per_role":{"cross":{"backend":"TORCH_SDPA"}}}' --diffusion-attention-config '{"default":{"backend":"FLASH_ATTN"},"per_role":{"cross":{"backend":"TORCH_SDPA"}}}'
``` ```
A backend that needs configuration exposes it as a typed field on the spec. Today the only A backend that needs configuration exposes it as a typed field on the spec:
one is `TRTLLM_ATTN`'s Skip-Softmax (see [below](#trtllm_attn-backend-and-skip-softmax)): `TRTLLM_ATTN`'s Skip-Softmax (see [below](#trtllm_attn-backend-and-skip-softmax)) and
`RAINFUSION_ATTN`'s RainFusion block (see [below](#rainfusion_attn-backend-and-block-sparse-video-attention)):
```bash ```bash
--diffusion-attention-config.default.backend TRTLLM_ATTN \ --diffusion-attention-config.default.backend TRTLLM_ATTN \
@@ -103,7 +105,7 @@ A plain dict is also accepted and normalized to `AttentionConfig`.
Auto-route preference, in order: Auto-route preference, in order:
1. `TRTLLM_ATTN` — on **datacenter** Blackwell (sm_100 / sm_103) when `flashinfer` is installed, the model's `head_dim` is 128, **and the model is mask-free** (see below) 1. `TRTLLM_ATTN` — on **datacenter** Blackwell (sm_100 / sm_103) when `flashinfer` is installed, the model's `head_dim` is 128, **and the model declares a compatible packed/mask-free path**
2. `CUDNN_ATTN` — when cuDNN ≥ 9.5 is available (ships in PyTorch 2.5+ wheels) 2. `CUDNN_ATTN` — when cuDNN ≥ 9.5 is available (ships in PyTorch 2.5+ wheels)
3. `FLASHINFER_ATTN` — when `flashinfer` is installed but cuDNN < 9.5 3. `FLASHINFER_ATTN` — when `flashinfer` is installed but cuDNN < 9.5
4. `FLASH_ATTN` — when `flash-attn` is installed with the Blackwell CUTE kernel 4. `FLASH_ATTN` — when `flash-attn` is installed with the Blackwell CUTE kernel
@@ -111,7 +113,7 @@ Auto-route preference, in order:
`TRTLLM_ATTN` is skipped on workstation Blackwell (sm_120 / sm_121) and for any `head_dim != 128`, so those GPUs keep the `CUDNN_ATTN` route described below. `TRTLLM_ATTN` is skipped on workstation Blackwell (sm_120 / sm_121) and for any `head_dim != 128`, so those GPUs keep the `CUDNN_ATTN` route described below.
`TRTLLM_ATTN` outranks `CUDNN_ATTN` on datacenter Blackwell because it is the only backend that can enable Skip-Softmax, not because its dense kernel is faster — dense, the two are comparable. It cannot honor an attention mask, so the auto-default only applies to pipelines verified mask-free (the Wan family); mask-using pipelines keep `CUDNN_ATTN`. `TRTLLM_ATTN` stays available everywhere via explicit `--diffusion-attention-backend TRTLLM_ATTN` (which raises clearly if a mask is received). `TRTLLM_ATTN` outranks `CUDNN_ATTN` on datacenter Blackwell for compatible packed/mask-free pipelines. Workstation Blackwell (sm_120 / sm_121) and pipelines that require attention masks retain their normal fallback.
The startup log line `Defaulting to diffusion attention backend CUDNN_ATTN (Blackwell sm_120, cuDNN 91002)` confirms the route. The startup log line `Defaulting to diffusion attention backend CUDNN_ATTN (Blackwell sm_120, cuDNN 91002)` confirms the route.
@@ -204,11 +206,80 @@ AttentionConfig(
) )
``` ```
**Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel **Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel
availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103** availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103**
(B300); `int8` QK kernels are compiled for **SM100 only**. (B300); `int8` QK kernels are compiled for **SM100 only**.
## RAINFUSION_ATTN Backend and Block-Sparse Video Attention
`RAINFUSION_ATTN` runs MindIE-SD's RainFusion (`rf_v2`) kernel on Ascend NPU. It pools each
128-token block of keys, ranks them per query block, and computes attention against only the
top-scoring ones. Video tokens are rearranged into `(t, h, w)` order first, so the kept blocks are
spatiotemporal neighbours rather than arbitrary rows of the packed sequence.
Only the video segment is sparsified. The prefix rows (text, visual conditions, audio) and the
first-frame blocks are always kept dense, which is why the realized sparsity is lower than the
nominal `sparsity` you configure. Anything the kernel cannot handle — a warmup denoise step, an
exempt layer, a sequence with no published video geometry, a video segment under 32 blocks —
delegates to `FLASH_ATTN`, so a model can select this backend unconditionally.
Enable it through the typed `block_sparse` block on the attention spec, shared by every
block-sparse backend:
| Key | Valid values | Meaning |
|---|---|---|
| `sparsity` | finite, `[0, 1]` | Nominal fraction of key blocks dropped per query block. `0` disables sparsity. Defaults to `0.8`. |
| `start_step` | `≥ 0` | Keep the first N denoise steps dense. Layout is decided early, so these steps dominate structural fidelity. |
| `skip_layers` | index selector, e.g. `"0-3,38"` | DiT blocks that always stay dense. |
```bash
vllm-omni serve MiniMaxAI/MiniMax-H3 \
--diffusion-attention-config '{"default": {"backend": "RAINFUSION_ATTN",
"block_sparse": {"sparsity": 0.8, "start_step": 0}}}'
```
Programmatically the same block is a typed `BlockSparseSpec` (values validated at construction):
```python
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, BlockSparseSpec
AttentionConfig(
default=AttentionSpec(
backend="RAINFUSION_ATTN",
block_sparse=BlockSparseSpec(sparsity=0.8, start_step=0, skip_layers="0-1"),
),
)
```
**Tune in the order `start_step` → `sparsity` → `skip_layers`.** Raise `start_step` first: it is the
cheapest way to recover structure, because the early high-noise steps decide global layout while the
later steps only refine texture. Then walk `sparsity` up to your quality bar. Reach for
`skip_layers` last, once an A/B against dense at the same seed points at specific blocks.
Requires Ascend NPU with `mindiesd`; selecting it on any other platform raises. It is also
incompatible with ring sequence parallelism, since `rf_v2` needs the whole key sequence to rank
blocks — use Ulysses SP (`ring_degree=1`).
### Which geometries run sparse
Sparsity is only applied when the video segment is a **multiple of 128 rows**, where the row count
is `latent_t × (height / 32) × (width / 32)`. Otherwise `rf_v2`'s block mask and the kernel's own
tiling disagree on the block count, and the block straddling the seam mixes video and prefix rows
that selection may then drop. Any resolution still runs — an unaligned geometry falls back to dense
attention and logs `RAINFUSION_ATTN staying dense` with the row count it computed — but it gets no
speedup, so pick an aligned geometry when you want one.
Alignment is necessary, not sufficient, for good quality. `rf_v2` groups video positions into 8x8
spatial tiles (two tiles fill one 128-row block), which is what makes a selected block a compact
patch of the frame. When the latent `h` or `w` is not a multiple of 8, the leftover rows or columns
are peeled off and appended as a flat run instead of being tiled, so they get pooled with spatially
distant positions and selection can no longer rank them meaningfully. This is silent, and invisible
to a `sparsity=0` check, because a fully populated mask does not care how blocks are grouped.
"Multiple of 8" on the latent grid means **width and height that are multiples of 256**. Aligned
resolutions off that grid still run sparse; they just lose more fidelity at the same `sparsity`,
which you can buy back with `start_step`.
## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell) ## End-to-End Benchmark (BF16, sm_120 RTX Pro 6000 Blackwell)
Same prompt and seed across runs. `Total generation time` from `text_to_video.py` / `text_to_image.py`. Same prompt and seed across runs. `Total generation time` from `text_to_video.py` / `text_to_image.py`.
@@ -184,10 +184,15 @@ class Flux2Transformer2DModel(nn.Module):
### How It Works ### How It Works
Distributed layerwise offloading extends single-GPU layerwise offloading to Distributed layerwise offloading extends single-GPU layerwise offloading to
multi-device deployments. Each DP rank stores only **1/dp_size** of the model multi-device deployments. In the default mode, each rank stores only
weights in host memory; full layer weights are reconstructed at runtime via **1/dp_size** of the model weights in host memory; full layer weights are
**AllGather** on a dedicated communication stream, overlapped with computation reconstructed at runtime via **AllGather** on a dedicated communication stream,
via a fixed double-buffer scheme. overlapped with computation via a fixed double-buffer scheme. The
`--dlo-no-use-allgather` mode instead streams standard-loader rank-local
weights independently and does not shard weights across ranks.
For the implementation design and compatibility matrix, see
[Distributed Layerwise Offload](../../design/feature/distributed_layerwise_offload.md).
**Key features:** **Key features:**
- **Weight sharding + AllGather**: each rank stores 1/dp_size of weights, - **Weight sharding + AllGather**: each rank stores 1/dp_size of weights,
@@ -219,7 +224,7 @@ vllm serve /path/to/model --omni \
--enable-distributed-layerwise-offload \ --enable-distributed-layerwise-offload \
--data-parallel-size 4 --data-parallel-size 4
# Without AllGather (each rank loads full weights, no sharding) # Without DLO AllGather (each rank streams standard-loader rank-local weights)
vllm serve /path/to/model --omni \ vllm serve /path/to/model --omni \
--enable-distributed-layerwise-offload \ --enable-distributed-layerwise-offload \
--data-parallel-size 4 \ --data-parallel-size 4 \
@@ -247,9 +252,9 @@ m = Omni(
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `--enable-distributed-layerwise-offload` | Enable DLO | `false` | | `--enable-distributed-layerwise-offload` | Enable DLO | `false` |
| `--data-parallel-size N` | Number of DP ranks for weight sharding | `1` | | `--data-parallel-size N` | Number of DP ranks; DLO weight-sharding group when AllGather is enabled | `1` |
| `--dlo-use-allgather` | Shard + AllGather (saves CPU, requires concurrent requests) | `true` | | `--dlo-use-allgather` | Shard + AllGather (saves CPU, requires concurrent requests) | `true` |
| `--dlo-no-use-allgather` | Full weights per rank (no sharding, no AllGather) | `false` | | `--dlo-no-use-allgather` | Use standard-loader rank-local weights; no DLO sharding or AllGather | `false` |
### How model weights are loaded (mmap path) ### How model weights are loaded (mmap path)
@@ -303,9 +308,15 @@ request while AllGather synchronizes only weight shards (request-independent).
### Limitations ### Limitations
- Online quantization (FP8) is incompatible with mmap loading — falls back to - Online quantization (FP8) is incompatible with mmap loading — falls back to
regular `load_weights()` automatically regular loading only when the model is outside the DLO+AllGather mmap path;
- Tensor Parallel is not supported (DLO uses DP-based sharding) online quantization with DLO+AllGather is rejected. Use
- HSDP + AllGather is rejected (would double-shard weights) `--dlo-no-use-allgather` or disable online quantization.
- Tensor Parallel > 1 is rejected in the DLO+AllGather mmap path because TP-aware
weight-loader callbacks are bypassed. The no-AllGather path retains the
standard TP loader and is experimental.
- HSDP + AllGather is rejected because it would double-shard parameters. HSDP
with no-AllGather is accepted at configuration level, but has limited
end-to-end validation.
- `num_inference_steps=None` is not allowed in DP multi-concurrency mode - `num_inference_steps=None` is not allowed in DP multi-concurrency mode
**Module Discovery** **Module Discovery**
@@ -338,7 +349,7 @@ OffloadBackend (base class)
├── LayerWiseOffloadBackend → uses LayerwiseOffloadHook ├── LayerWiseOffloadBackend → uses LayerwiseOffloadHook
│ (single-GPU, full weights on host) │ (single-GPU, full weights on host)
└── DistributedLayerwiseOffloadBackend → uses DistributedLayerwiseOffloadHook └── DistributedLayerwiseOffloadBackend → uses DistributedLayerwiseOffloadHook
(multi-GPU, 1/dp_size sharded weights + AllGather) (multi-GPU, sharded AllGather or rank-local mode)
``` ```
Factory function `get_offload_backend()` selects the appropriate backend based on Factory function `get_offload_backend()` selects the appropriate backend based on
@@ -369,4 +380,10 @@ reasoner/generator components inside the model forward pass.
**Notes:** **Notes:**
- Model-Level Offloading is expected to be supported by all common diffusion models (DiT and encoders) naturally - Model-Level Offloading is expected to be supported by all common diffusion models (DiT and encoders) naturally
- Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attrs` pointing to transformer blocks - Layerwise Offloading requires DiT class to define `_layerwise_offload_blocks_attrs` pointing to transformer blocks
- Distributed Layerwise Offloading works with any model that supports Layerwise Offloading — no additional model changes required. See [Cosmos3 DistOffload recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md) for usage examples. - Distributed Layerwise Offloading reuses the layerwise block topology, but the
model and parallelism combination still needs validation. AllGather mode
additionally requires a compatible checkpoint/mmap layout; no-AllGather mode
retains the standard loader's rank-local tensors. See the
[DLO design](../../design/feature/distributed_layerwise_offload.md) and
[Cosmos3 DistOffload recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/cosmos3/Cosmos3-DistOffload.md)
for usage examples.
+5 -5
View File
@@ -59,7 +59,7 @@ Memory optimization methods help reduce GPU memory usage, enabling inference on
| Method | Description | Best For | | Method | Description | Best For |
|--------|-------------|----------| |--------|-------------|----------|
| **[CPU Offload](diffusion/cpu_offload_diffusion.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 Parallelism](diffusion/parallelism/vae_parallelism.md)** | Distributes VAE decode work across GPUs | High-resolution generation with reduced VAE memory peak |
@@ -104,8 +104,8 @@ The following tables show which models support each feature:
> Notes: > Notes:
> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload_diffusion.md). > 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload.md).
> 2. The **💾Quantization** column is collapsed for readability. See [Quantization Overview](quantization/overview.md) for per-method and per-model support details. > 2. The **💾Quantization** column is collapsed for readability. See [Quantization](quantization/overview.md) for per-method and per-model support details.
### ImageGen ### ImageGen
@@ -274,9 +274,9 @@ Measured on NVIDIA H800:
**Memory Optimization:** **Memory Optimization:**
- **[CPU Offload Guide](diffusion/cpu_offload_diffusion.md)** - Offload model components to CPU, reduce GPU memory usage - **[CPU Offload Guide](diffusion/cpu_offload.md)** - Offload model components to CPU, reduce GPU memory usage
- **[VAE Parallelism Guide](diffusion/parallelism/vae_parallelism.md)** - Distribute VAE decode work across GPUs for high-resolution images and videos - **[VAE Parallelism Guide](diffusion/parallelism/vae_parallelism.md)** - Distribute VAE decode work across GPUs for high-resolution images and videos
- **[Quantization Overview](quantization/overview.md)** - Overview of quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models - **[Quantization](quantization/overview.md)** - Quantization methods for diffusion, multi-stage omni/TTS, and multi-stage diffusion models
**Extensions:** **Extensions:**
+3
View File
@@ -5,6 +5,9 @@ path. The same configuration entrypoint is used across diffusion-only models,
multi-stage omni/TTS models, and multi-stage diffusion models, but each model multi-stage omni/TTS models, and multi-stage diffusion models, but each model
type has a different quantization scope. type has a different quantization scope.
For the internal architecture and backend extension points, see the
[quantization design document](../../design/feature/quantization.md).
## Quantization Modes ## Quantization Modes
| Mode | Guide | Description | Methods | | Mode | Guide | Description | Methods |
+2
View File
@@ -75,6 +75,8 @@ plugins:
redirect_maps: redirect_maps:
user_guide/diffusion/request_batching.md: user_guide/diffusion/execution_modes.md user_guide/diffusion/request_batching.md: user_guide/diffusion/execution_modes.md
user_guide/diffusion/step_execution.md: user_guide/diffusion/execution_modes.md user_guide/diffusion/step_execution.md: user_guide/diffusion/execution_modes.md
user_guide/diffusion/cpu_offload_diffusion.md: user_guide/diffusion/cpu_offload.md
user_guide/quantization/index.md: user_guide/quantization/overview.md
design/feature/diffusion_request_level_batching.md: design/feature/diffusion_continuous_batching.md design/feature/diffusion_request_level_batching.md: design/feature/diffusion_continuous_batching.md
design/feature/diffusion_step_execution.md: design/feature/diffusion_continuous_batching.md design/feature/diffusion_step_execution.md: design/feature/diffusion_continuous_batching.md
- glightbox - glightbox
+10 -7
View File
@@ -250,13 +250,16 @@ markers = [
"npu: Tests that run on NPU/Ascend (auto-added)", "npu: Tests that run on NPU/Ascend (auto-added)",
"musa: Tests that run on MUSA/Moore Threads (auto-added)", "musa: Tests that run on MUSA/Moore Threads (auto-added)",
# specified computation resources marks (auto-added) # specified computation resources marks (auto-added)
"H100: Tests that require H100 GPU", # Tag with [hardware-resource] for tests.helpers.mark.get_hardware_mark_list().
"L4: Tests that require L4 GPU", "H100: [hardware-resource] Tests that require H100 GPU",
"MI325: Tests that require MI325 GPU (AMD/ROCm)", "H200: [hardware-resource] Tests that require H200 GPU",
"B60: Tests that require Intel Arc Pro B60 XPU", "L4: [hardware-resource] Tests that require L4 GPU",
"S5000: Tests that require S5000 GPU (Moore Threads/MUSA)", "B200: [hardware-resource] Tests that require B200 GPU",
"A2: Tests that require A2 NPU", "MI325: [hardware-resource] Tests that require MI325 GPU (AMD/ROCm)",
"A3: Tests that require A3 NPU", "B60: [hardware-resource] Tests that require Intel Arc Pro B60 XPU",
"S5000: [hardware-resource] Tests that require S5000 GPU (Moore Threads/MUSA)",
"A2: [hardware-resource] Tests that require A2 NPU",
"A3: [hardware-resource] Tests that require A3 NPU",
"distributed_cuda: Tests that require multi cards on CUDA platform", "distributed_cuda: Tests that require multi cards on CUDA platform",
"distributed_rocm: Tests that require multi cards on ROCm platform", "distributed_rocm: Tests that require multi cards on ROCm platform",
"distributed_xpu: Tests that require multi cards on XPU platform", "distributed_xpu: Tests that require multi cards on XPU platform",
+13 -14
View File
@@ -12,19 +12,17 @@
- Maintainer: Community - Maintainer: Community
This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for MUSA environments. This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for MUSA environments.
MiniMax H3 has two independently served checkpoint partitions: The modular H3 pipeline can load both task-specific DiTs, but this MUSA recipe
uses a **single task partition**: `FL2VA` for `t2va`/`fl2va`, or `Ref2VA` for
- `FL2VA` serves text-to-video+audio (`t2va`) and `ref2va`. This avoids loading both DiTs.
first-frame-to-video+audio (`fl2va`).
- `Ref2VA` serves image/audio or video-reference generation (`ref2va`).
## Prerequisites ## Prerequisites
### Checkpoint ### Checkpoint
Only download the partition needed by the server. Downloading the whole model Download the partition required by the selected startup task. The commands below
repository also retrieves duplicate shared components and the original run a single-task server; do not use the modular default (combined) mode on MUSA
top-level pipeline layout. until its two-DiT memory and performance profile is validated.
For T2VA and FL2VA: For T2VA and FL2VA:
@@ -66,14 +64,13 @@ soundfile. Formats that libsndfile cannot read are demuxed through ffmpeg.
## Start a server ## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA Use the partition path and set `--task-type` explicitly: `fl2va` for T2VA/FL2VA
and FL2VA, or to `Ref2VA` for Ref2VA. or `ref2va` for Ref2VA.
The validated Ref2VA configuration uses tensor parallelism across four MTT The validated Ref2VA configuration uses tensor parallelism across four MTT S5000 GPUs and offloads inactive model components to CPU:
S5000 GPUs and offloads inactive model components to CPU:
```bash ```bash
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL="${MODEL_ROOT}/Ref2VA"
export PORT=8091 export PORT=8091
MUSA_VISIBLE_DEVICES=0,1,2,3 \ MUSA_VISIBLE_DEVICES=0,1,2,3 \
@@ -85,6 +82,7 @@ vllm serve "${MODEL}" \
--port "${PORT}" \ --port "${PORT}" \
--trust-remote-code \ --trust-remote-code \
--num-gpus 4 \ --num-gpus 4 \
--task-type ref2va \
--tensor-parallel-size 4 \ --tensor-parallel-size 4 \
--vae-patch-parallel-size 4 \ --vae-patch-parallel-size 4 \
--enable-cpu-offload \ --enable-cpu-offload \
@@ -133,7 +131,8 @@ Keep `--vae-use-tiling` enabled for this serving profile.
- Keep Ring Attention at degree 1. The current Ring path does not preserve - Keep Ring Attention at degree 1. The current Ring path does not preserve
MiniMax H3 packed padding boundaries. MiniMax H3 packed padding boundaries.
- H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1. - H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
- Each server process loads only one checkpoint partition. - This MUSA recipe uses an explicitly selected single task partition; combined
serving loads both DiTs.
- H3 currently executes one generation request per diffusion batch. - H3 currently executes one generation request per diffusion batch.
- FP8 quantization has not been enabled for MiniMax H3. - FP8 quantization has not been enabled for MiniMax H3.
- MP3, M4A, MP4, and reference-video audio fallback requires `ffmpeg` on - MP3, M4A, MP4, and reference-video audio fallback requires `ffmpeg` on
+41 -16
View File
@@ -26,12 +26,12 @@ environments. Differences from the GPU path:
### Checkpoint ### Checkpoint
Same as the GPU recipe — Hugging Face access approval is required: Same as the GPU recipe — Hugging Face access approval is required. Authenticate
once; `vllm serve` downloads the required nested components automatically:
```bash ```bash
hf auth login hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3 export MODEL=MiniMaxAI/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
``` ```
### Environment ### Environment
@@ -50,7 +50,8 @@ uv pip install -e .
``` ```
Install the **mindie-sd** operator library to enable Ascend-optimized fused Install the **mindie-sd** operator library to enable Ascend-optimized fused
operators (`adalayernorm`, etc.): operators (`adalayernorm`, etc.) and the RainFusion `rf_v2` block-sparse
attention kernel:
```bash ```bash
git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD
@@ -72,24 +73,21 @@ pip install mindiesd-*.whl
## Start a server ## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA Pass the repository ID directly. The pipeline loads the two nested DiTs while
and FL2VA requests, or to `Ref2VA` for Ref2VA requests. sharing the tokenizer, processor, text encoder, and VAEs from `FL2VA`.
### Multi-NPU: 768P validated configuration ### Multi-NPU: 768P combined-service configuration
Validated on eight NPUs of an Atlas 800I A3 server with Ulysses sequence Use Ulysses sequence parallelism degree 8, text-encoder tensor parallelism
parallelism degree 8, text-encoder tensor parallelism degree 8, native tiled degree 8, native tiled VAE patch parallelism degree 8, and layerwise offload:
VAE patch parallelism degree 8, and layerwise offload:
```bash ```bash
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7 export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
export PORT=9098 export PORT=9098
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL=MiniMaxAI/MiniMax-H3
export VLLM_WORKER_MULTIPROC_METHOD=spawn export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800
export PYTHONDONTWRITEBYTECODE=1 export PYTHONDONTWRITEBYTECODE=1
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
vllm serve "${MODEL}" \ vllm serve "${MODEL}" \
--omni \ --omni \
@@ -111,12 +109,31 @@ Do not add `--enforce-eager`. The first request includes regional
compilation; warm the server once before measuring steady-state latency. compilation; warm the server once before measuring steady-state latency.
H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1. H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
To serve Ref2VA, stop the FL2VA server and restart with: The same endpoint accepts `task=t2va`, `task=fl2va`, and `task=ref2va`; no
partition restart is required. Layerwise offload applies to both DiTs.
### Optional optimizations
Two independent optimizations may be enabled on top of the configuration
above. Both are validated for T2VA only.
**RainFusion block-sparse attention** — switch the attention backend, keeping
every other flag unchanged:
```bash ```bash
export MODEL="${MODEL_ROOT}/Ref2VA" --diffusion-attention-backend RAINFUSION_ATTN
``` ```
**INT8 online quantization** — add one flag to the server command above:
```bash
--quantization int8
```
Keep `--ring 1` when using RainFusion: the `rf_v2` kernel ranks key blocks
over the whole sequence, so ring parallelism would split away the keys it
needs. Scale with `--usp` instead.
## HTTP API examples ## HTTP API examples
The request format is identical to the GPU recipe; see The request format is identical to the GPU recipe; see
@@ -145,7 +162,8 @@ throughput guarantee.
## Known limitations ## Known limitations
- Each server process loads only one checkpoint partition. - Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads
both task-specific DiTs, and loads one copy of every shared component.
- H3 currently executes one generation request per diffusion batch. - H3 currently executes one generation request per diffusion batch.
- The first regional-compile request is a warmup and should not be included - The first regional-compile request is a warmup and should not be included
in steady-state performance measurements. in steady-state performance measurements.
@@ -154,9 +172,16 @@ throughput guarantee.
the same HTTP request contract. the same HTTP request contract.
- VAE patch parallelism requires size 1 or the full DiT group size and - VAE patch parallelism requires size 1 or the full DiT group size and
supports the H3 native `tile` mode only. supports the H3 native `tile` mode only.
- RainFusion block-sparse attention and INT8 quantization are validated for
T2VA only; use the BF16 dense configuration for FL2VA and Ref2VA.
- Online quantization cannot be combined with distributed layerwise offload
while AllGather is enabled; pass `--dlo-no-use-allgather` in that case.
## Additional resources ## Additional resources
- [MiniMax-H3.md](MiniMax-H3.md) — full GPU guide - [MiniMax-H3.md](MiniMax-H3.md) — full GPU guide
- [Attention backends § RAINFUSION_ATTN](../../docs/user_guide/diffusion/attention_backends.md#rainfusion_attn-backend-and-block-sparse-video-attention)
— RainFusion knobs and tuning
- [Int8 quantization](../../docs/user_guide/quantization/int8.md)
- [Supported models](../../docs/models/supported_models.md) - [Supported models](../../docs/models/supported_models.md)
- [Video API](../../docs/serving/videos_api.md) - [Video API](../../docs/serving/videos_api.md)
+116 -45
View File
@@ -12,27 +12,34 @@
- Maintainer: Community - Maintainer: Community
MiniMax H3 is a CFG-distilled joint video/audio diffusion transformer. Its MiniMax H3 is a CFG-distilled joint video/audio diffusion transformer. Its
checkpoint has two independently served partitions: checkpoint has two task-specific DiT partitions:
- `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio - `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio
(`fl2va`) (`fl2va`)
- `Ref2VA`: up to 9 images, 3 videos, and 3 audio references in supported - `Ref2VA`: up to 9 images, 3 videos, and 3 audio references in supported
image/video/audio combinations (`ref2va`; audio-only is rejected) image/video/audio combinations (`ref2va`; audio-only is rejected)
One vLLM-Omni diffusion stage can load both DiTs while instantiating the
tokenizer, processor, Qwen3-VL text encoder, video VAE, and audio VAE only
once. Requests select the DiT with `extra_params.task`.
The generated MP4 contains H.264 video and synchronized stereo audio. The generated MP4 contains H.264 video and synchronized stereo audio.
## Prerequisites ## Prerequisites
The checkpoint requires Hugging Face access approval. Download it and point The checkpoint requires Hugging Face access approval. Authenticate once;
`MODEL_ROOT` at the directory containing the `FL2VA` and `Ref2VA` `vllm serve` downloads the required components automatically:
subdirectories:
```bash ```bash
hf auth login hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3 export MODEL=MiniMaxAI/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
``` ```
The vLLM-Omni pipeline downloads `FL2VA/**`, `Ref2VA/model_index.json`, and
`Ref2VA/transformer/**`. It does not download or load the diffusers-format
`transformer`, `transformer_ref`, or `vae` weights at the repository root, nor
duplicate Ref2VA copies of shared components.
Install vLLM-Omni from the checkout containing MiniMax H3 support. The Install vLLM-Omni from the checkout containing MiniMax H3 support. The
two-GPU RTX 5090/4090 profiles use cuDNN attention and do not need two-GPU RTX 5090/4090 profiles use cuDNN attention and do not need
FlashAttention-4. Install the optional dependency only for the four-GPU FlashAttention-4. Install the optional dependency only for the four-GPU
@@ -41,6 +48,13 @@ B300/GB200 `FLASH_ATTN` profile:
```bash ```bash
uv venv uv venv
source .venv/bin/activate source .venv/bin/activate
uv pip install -e .
```
To keep FA4 available as an explicit option on Blackwell, install the
FlashAttention-4 extra:
```bash
uv pip install -e '.[fa4]' uv pip install -e '.[fa4]'
``` ```
@@ -49,16 +63,17 @@ reference-video preparation and MP4 output.
## Start a server ## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA Pass the repository ID directly. The pipeline uses `FL2VA` for model discovery
and FL2VA requests, or to `Ref2VA` for either Ref2VA request. and shared components, and loads the second DiT from `Ref2VA/transformer`.
### Memory and storage requirements ### Memory and storage requirements
Treat GPU HBM, host RAM, and checkpoint storage as separate requirements. Each Treat GPU HBM, host RAM, and checkpoint storage as separate requirements. Each
H3 checkpoint partition (`FL2VA` or `Ref2VA`) contains about **134 GiB** of H3 checkpoint partition (`FL2VA` or `Ref2VA`) contains about **134 GiB** of
BF16 safetensors (about **135 GiB** on disk). Keeping both partitions locally BF16 safetensors (about **135 GiB** on disk). Keeping both partitions
therefore needs roughly **270 GiB** of model storage, although the examples locally therefore needs roughly **270 GiB** of model storage. A combined
serve only one partition at a time. service downloads both; `--task-type fl2va` or `--task-type ref2va` downloads
only the selected partition.
CPU offload and distributed layerwise offload reduce GPU residency; they do CPU offload and distributed layerwise offload reduce GPU residency; they do
not make the model weights disappear. With `--dlo-no-use-allgather`, each not make the model weights disappear. With `--dlo-no-use-allgather`, each
@@ -79,7 +94,7 @@ This matches the accuracy-qualified reference path and prevents the Qwen3-VL
encoder and DiT from being resident on the GPU at the same time. encoder and DiT from being resident on the GPU at the same time.
```bash ```bash
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091 export PORT=8091
CUDA_VISIBLE_DEVICES=0 \ CUDA_VISIBLE_DEVICES=0 \
@@ -96,8 +111,8 @@ vllm serve "${MODEL}" \
``` ```
Use a GPU with enough memory for the active H3 component and enough system RAM Use a GPU with enough memory for the active H3 component and enough system RAM
for the offloaded components. CPU offload reduces GPU memory pressure but adds for both offloaded DiTs plus the shared components. Model-level offload keeps
PCIe/NVLink transfer latency. the two DiTs mutually exclusive on GPU, but adds PCIe/NVLink transfer latency.
### Two 24/32 GB GPUs: TP2 distributed layerwise offload ### Two 24/32 GB GPUs: TP2 distributed layerwise offload
@@ -109,7 +124,7 @@ copied to the GPUs once per denoise stage, reused by every sampling step, and
released before VAE decode so the decoder can reuse their HBM. released before VAE decode so the decoder can reuse their HBM.
```bash ```bash
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091 export PORT=8091
CUDA_VISIBLE_DEVICES=0,1 \ CUDA_VISIBLE_DEVICES=0,1 \
@@ -120,6 +135,7 @@ vllm serve "${MODEL}" \
--host 0.0.0.0 \ --host 0.0.0.0 \
--port "${PORT}" \ --port "${PORT}" \
--trust-remote-code \ --trust-remote-code \
--task-type fl2va \
--num-gpus 2 \ --num-gpus 2 \
--tensor-parallel-size 2 \ --tensor-parallel-size 2 \
--usp 1 \ --usp 1 \
@@ -191,23 +207,24 @@ bash examples/offline_inference/minimax_h3/run_h3_2gpu_all_tasks.sh
The script selects 20 resident layers for `PROFILE=rtx5090` and 12 for The script selects 20 resident layers for `PROFILE=rtx5090` and 12 for
`PROFILE=rtx4090`; `DLO_RESIDENT_LAYERS=N` overrides either default. `PROFILE=rtx4090`; `DLO_RESIDENT_LAYERS=N` overrides either default.
### Four GPUs: measured best-practice throughput ### Four GPUs: throughput-oriented combined service
The best validated four-GPU configuration on four NVIDIA B300 GPUs is: For a combined service on four high-memory GPUs, use:
- no CPU or layerwise offload; - no CPU or layerwise offload;
- Ulysses sequence parallelism degree 4; - Ulysses sequence parallelism degree 4;
- native tiled VAE patch parallelism degree 4; - native tiled VAE patch parallelism degree 4;
- regional `torch.compile` for the repeated DiT blocks; - regional `torch.compile` for the repeated DiT blocks;
- CuTe FlashAttention-4 through the `FLASH_ATTN` backend, with Ring and TP - dense BF16 `TRTLLM_ATTN`, with Ring and TP left at 1.
left at 1.
Both DiTs remain resident in this no-offload configuration. If they do not fit,
use model-level CPU offload.
```bash ```bash
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091 export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \ CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \ VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \ vllm serve "${MODEL}" \
@@ -220,19 +237,76 @@ vllm serve "${MODEL}" \
--ring 1 \ --ring 1 \
--vae-patch-parallel-size 4 \ --vae-patch-parallel-size 4 \
--vae-parallel-mode tile \ --vae-parallel-mode tile \
--vae-use-tiling \ --vae-use-tiling
--diffusion-attention-backend FLASH_ATTN
``` ```
On Blackwell, `FLASH_ATTN` selects FA4. Confirm the server log contains
`Using CuTe FlashAttention-4 on Blackwell` before recording measurements.
Do not add `--enforce-eager` to this performance configuration. The first Do not add `--enforce-eager` to this performance configuration. The first
request includes regional compilation; warm the server once before measuring request includes regional compilation; warm the server once before measuring
steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain
1. The H3 VAE supports its native `tile` mode, not 1. The H3 VAE supports its native `tile` mode, not
`spatial_shard_height` or `spatial_shard_width`. `spatial_shard_height` or `spatial_shard_width`.
### Attention Backends
On datacenter Blackwell GPUs, MiniMax H3 defaults to dense BF16
`TRTLLM_ATTN`; no attention backend flag is required. To select it explicitly,
use:
```bash
--diffusion-attention-backend TRTLLM_ATTN
```
Stable measurements with the four-GPU profile above put dense `TRTLLM_ATTN`
and FA4 within 2% of each other. `TRTLLM_ATTN` remains the datacenter Blackwell
default and enables the optional optimizations below. Confirm the server log
contains `Defaulting to diffusion attention backend TRTLLM_ATTN` before
recording measurements when using the default selection.
FA4 remains available by explicitly selecting the `FLASH_ATTN` backend:
```bash
--diffusion-attention-backend FLASH_ATTN
```
On Blackwell, `FLASH_ATTN` selects FA4. Confirm the server log contains
`Using CuTe FlashAttention-4 on Blackwell` before recording FA4 measurements.
`TRTLLM_ATTN` additionally supports two **lossy** optimizations for the long main
DiT attention sequence: SAGE attention quantization and Skip-Softmax Sparse
Attention. SAGE quantizes Q/K to the configured dtype and V to FP8. This example uses
`fp8_e4m3` for Q/K; B200 also supports `int8` Q/K. The TRTLLM SAGE path fixes V
to FP8, so vLLM-Omni only exposes the Q/K dtype. The token refiner is a short
attention path, so the `per_role` override leaves SAGE and Skip-Softmax disabled
for it. The example enables the calibration-free Skip-Softmax path with
`threshold=0.05`, after the normalized timestep reaches `0.97`:
```bash
--diffusion-attention-config '{
"default": {
"backend": "TRTLLM_ATTN",
"quant": {
"dtype_qk": "fp8_e4m3",
"q_block_size": 1,
"k_block_size": 16
},
"skip_softmax": {
"threshold": 0.05,
"disabled_until_timestep": 0.97
}
},
"per_role": {
"minimax_h3.token_refiner": {
"backend": "TRTLLM_ATTN"
}
}
}'
```
For configuration details, see
[TRTLLM_ATTN Backend and Skip-Softmax](https://github.com/vllm-project/vllm-omni/blob/main/docs/user_guide/diffusion/attention_backends.md#trtllm_attn-backend-and-skip-softmax)
and
[TRTLLM_ATTN SAGE Quantization](https://github.com/vllm-project/vllm-omni/blob/main/docs/user_guide/diffusion/attention_backends.md#trtllm_attn-sage-quantization).
### Text encoder tensor parallelism ### Text encoder tensor parallelism
The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by
@@ -243,11 +317,10 @@ vLLM-style tensor-parallel layers and runs with distributed collectives over
its own encoder process group): its own encoder process group):
```bash ```bash
export MODEL="${MODEL_ROOT}/FL2VA" export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091 export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \ CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \ VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \ VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \ vllm serve "${MODEL}" \
@@ -261,8 +334,7 @@ vllm serve "${MODEL}" \
--text-encoder-tp-size 4 \ --text-encoder-tp-size 4 \
--vae-patch-parallel-size 4 \ --vae-patch-parallel-size 4 \
--vae-parallel-mode tile \ --vae-parallel-mode tile \
--vae-use-tiling \ --vae-use-tiling
--diffusion-attention-backend FLASH_ATTN
``` ```
`N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so `N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so
@@ -275,12 +347,9 @@ the reference path within bf16 rounding: every encoder rank all-reduces the
row-parallel projections, so the full `[seq, 5120]` layer-50 hidden state is row-parallel projections, so the full `[seq, 5120]` layer-50 hidden state is
replicated on every rank. replicated on every rank.
To serve Ref2VA, stop the FL2VA server and restart the same one- or four-GPU No restart is needed: `task=fl2va` routes to `FL2VA/transformer`, while
command with: `task=ref2va` routes to
`Ref2VA/transformer`. T2VA uses the FL2VA DiT.
```bash
export MODEL="${MODEL_ROOT}/Ref2VA"
```
### Online FP8 quantization ### Online FP8 quantization
@@ -336,7 +405,7 @@ export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync"
### 1. T2VA: text to video and audio ### 1. T2VA: text to video and audio
Run this request against the `FL2VA` partition: Run this request against the combined service:
```bash ```bash
curl -sS -X POST "${API_URL}" \ curl -sS -X POST "${API_URL}" \
@@ -354,7 +423,7 @@ curl -sS -X POST "${API_URL}" \
### 2. FL2VA: first frame to video and audio ### 2. FL2VA: first frame to video and audio
Run this request against the `FL2VA` partition. When width and height are Run this request against the combined service. When width and height are
omitted, H3 preserves the first-frame aspect ratio and uses a 768-pixel short omitted, H3 preserves the first-frame aspect ratio and uses a 768-pixel short
edge. edge.
@@ -393,10 +462,11 @@ curl -sS -X POST "${API_URL}" \
### 3. Ref2VA: image-only, image/audio, or mixed references ### 3. Ref2VA: image-only, image/audio, or mixed references
Run these requests against the `Ref2VA` partition. Image-only Ref2VA omits Run these requests against the combined service or a Ref2VA-only service.
`audio_reference`; adding one or more audio references is optional. The typed Image-only Ref2VA omits `audio_reference`; adding one or more audio references
fields accept one object or an ordered JSON list. `audio_reference` accepts an is optional. The typed fields accept one object or an ordered JSON list.
HTTP(S) URL or a `data:` URL. `audio_reference` accepts an HTTP(S) URL or a `data:` URL. In one terminal,
expose the local reference assets to the serving host:
```bash ```bash
python -m http.server 8092 \ python -m http.server 8092 \
@@ -446,7 +516,7 @@ shorter, the reference soundtrack is truncated to the generated clip.
### 4. Ref2VA: video, separate audio, and mixed references ### 4. Ref2VA: video, separate audio, and mixed references
Run this request against the `Ref2VA` partition. Repeat the Run this request against the combined service. Repeat the
`input_references` multipart field once per source video. H3 consumes the `input_references` multipart field once per source video. H3 consumes the
videos in form order and preserves their original soundtracks during videos in form order and preserves their original soundtracks during
conditioning. conditioning.
@@ -508,7 +578,7 @@ than one is requested.
| Parameter | Recommended value | Notes | | Parameter | Recommended value | Notes |
|-----------|-------------------|-------| |-----------|-------------------|-------|
| `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; must match the served partition | | `task` | `t2va`, `fl2va`, or `ref2va` | Passed in `extra_params`; selects the task-specific DiT |
| `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count | | `duration` | Workload-specific | Decimal seconds in `extra_params`; converted to H3-compatible frame count |
| `fps` | `24` | H3 output FPS is fixed | | `fps` | `24` | H3 output FPS is fixed |
| `num_inference_steps` | `50` | Matches the reference accuracy workloads | | `num_inference_steps` | `50` | Matches the reference accuracy workloads |
@@ -550,7 +620,8 @@ reduction.
## Known limitations ## Known limitations
- Each server process loads only one checkpoint partition. - Combined serving requires sibling `FL2VA` and `Ref2VA` directories, loads
both task-specific DiTs, and loads shared components once from `FL2VA`.
- H3 currently executes one generation request per diffusion batch. - H3 currently executes one generation request per diffusion batch.
- The first regional-compile request is a warmup and should not be included in - The first regional-compile request is a warmup and should not be included in
steady-state performance measurements. steady-state performance measurements.
+18 -10
View File
@@ -56,10 +56,14 @@ vllm serve /path/to/Cosmos3-Super \
--data-parallel-size 4 --data-parallel-size 4
``` ```
### Without AllGather (full weights per rank, no sharding) ### Without DLO AllGather (rank-local weights, no DLO sharding)
Use `--dlo-no-use-allgather` when you want each rank to load full weights independently — Use `--dlo-no-use-allgather` when you want each rank to stream the standard
no AllGather synchronization, no concurrent request requirement, but N× host memory. loader's rank-local weights independently — no DLO AllGather synchronization,
no concurrent request requirement, but no DLO weight sharding and higher host
memory use. In pure DP, this is equivalent to independent full-weight
layerwise-offload replicas; with TP or HSDP, each rank retains the standard
loader's local tensor layout.
```bash ```bash
vllm serve /path/to/Cosmos3-Nano \ vllm serve /path/to/Cosmos3-Nano \
@@ -154,9 +158,9 @@ wait
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `--enable-distributed-layerwise-offload` | Enable DLO with H2D + AllGather overlap | `false` | | `--enable-distributed-layerwise-offload` | Enable DLO with H2D + AllGather overlap | `false` |
| `--data-parallel-size N` | Number of DP ranks (weight sharding + concurrent requests) | `1` | | `--data-parallel-size N` | Number of DP ranks; DLO weight sharding + concurrent requests when AllGather is enabled | `1` |
| `--dlo-use-allgather` | Use shard + AllGather for weight reconstruction (recommended) | `true` | | `--dlo-use-allgather` | Use shard + AllGather for weight reconstruction (recommended) | `true` |
| `--dlo-no-use-allgather` | Each rank loads full weights independently (no sharding, no AllGather) | `false` | | `--dlo-no-use-allgather` | Stream standard-loader rank-local weights independently (no DLO sharding, no DLO AllGather) | `false` |
| `--usp N` | Use SP instead of DP for weight sharding (long sequences) | `1` | | `--usp N` | Use SP instead of DP for weight sharding (long sequences) | `1` |
## Mode comparison ## Mode comparison
@@ -166,7 +170,7 @@ wait
| DLO + AllGather (DP4) | `--enable-distributed-layerwise-offload --data-parallel-size 4` | 1/4 model | 2 blocks | **3.3× HSDP** | Max throughput, short sequences | | DLO + AllGather (DP4) | `--enable-distributed-layerwise-offload --data-parallel-size 4` | 1/4 model | 2 blocks | **3.3× HSDP** | Max throughput, short sequences |
| DLO + AllGather (DP2) | `--enable-distributed-layerwise-offload --data-parallel-size 2` | 1/2 model | 2 blocks | 2× HSDP | Balanced throughput + memory | | DLO + AllGather (DP2) | `--enable-distributed-layerwise-offload --data-parallel-size 2` | 1/2 model | 2 blocks | 2× HSDP | Balanced throughput + memory |
| DLO + AllGather (SP4) | `--enable-distributed-layerwise-offload --usp 4` | 1/4 model | 2 blocks | 1× (single req) | Long sequences (720p+) | | DLO + AllGather (SP4) | `--enable-distributed-layerwise-offload --usp 4` | 1/4 model | 2 blocks | 1× (single req) | Long sequences (720p+) |
| DLO no-AllGather | `--enable-distributed-layerwise-offload --dlo-no-use-allgather` | Full model | 2 blocks | 0.4× HSDP | No AllGather overhead, high CPU | | DLO no-AllGather | `--enable-distributed-layerwise-offload --dlo-no-use-allgather` | Standard-loader rank-local weights | 2 blocks | 0.4× HSDP | No DLO AllGather overhead, high CPU |
| HSDP | `--use-hsdp --hsdp-shard-size 4` | 0 | 1/4 model | 1× (baseline) | Weights fit in HBM | | HSDP | `--use-hsdp --hsdp-shard-size 4` | 0 | 1/4 model | 1× (baseline) | Weights fit in HBM |
## Memory expectations ## Memory expectations
@@ -185,7 +189,11 @@ wait
- `--dlo-use-allgather` (default) requires all concurrent requests to have the same - `--dlo-use-allgather` (default) requires all concurrent requests to have the same
`num_inference_steps` (AllGather is a collective that needs all ranks synchronized) `num_inference_steps` (AllGather is a collective that needs all ranks synchronized)
- Online quantization (FP8) is incompatible with mmap loading — falls back to regular - Online quantization (FP8) is incompatible with the DLO+AllGather mmap path;
`load_weights()` automatically use `--dlo-no-use-allgather` or disable online quantization
- Tensor Parallel is not supported (DLO uses DP-based sharding, a different dimension) - Tensor Parallel > 1 is rejected in the DLO+AllGather mmap path. TP with
- HSDP + AllGather is rejected (would double-shard weights) `--dlo-no-use-allgather` retains the standard TP loader and should be treated
as an experimental compatibility path
- HSDP + AllGather is rejected (would double-shard weights). HSDP with
`--dlo-no-use-allgather` is accepted at configuration level, but is not yet a
broadly validated production combination
+99 -7
View File
@@ -39,7 +39,7 @@ from vllm_omni.config.stage_config import (
load_deploy_config, load_deploy_config,
merge_pipeline_deploy, merge_pipeline_deploy,
) )
from vllm_omni.engine.stage_init_utils import build_engine_args_dict from vllm_omni.engine.stage_init_utils import build_legacy_engine_args_dict
pytestmark = [pytest.mark.core_model, pytest.mark.cpu] pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -124,10 +124,16 @@ def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(mod
assert omni_stage.model_config.enforce_eager == engine_args.get("enforce_eager", False) assert omni_stage.model_config.enforce_eager == engine_args.get("enforce_eager", False)
assert omni_stage.load_config.load_format == engine_args.get("load_format", "auto") assert omni_stage.load_config.load_format == engine_args.get("load_format", "auto")
assert omni_stage.load_config.tokenizer_mode == engine_args.get("tokenizer_mode", "auto") assert omni_stage.load_config.tokenizer_mode == engine_args.get("tokenizer_mode", "auto")
assert omni_stage.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization", 0.90) assert omni_stage.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization")
assert omni_stage.scheduler_config.max_num_seqs == engine_args.get("max_num_seqs", 128) assert omni_stage.cache_config.enable_prefix_caching == engine_args.get("enable_prefix_caching")
expected_disable_hybrid = engine_args.get("disable_hybrid_kv_cache_manager")
if omni_stage.stage_pipeline_config.execution_type == StageExecutionType.LLM_GENERATION:
expected_disable_hybrid = True if expected_disable_hybrid is None else expected_disable_hybrid
assert omni_stage.cache_config.disable_hybrid_kv_cache_manager == expected_disable_hybrid
assert omni_stage.scheduler_config.max_num_seqs == engine_args.get("max_num_seqs")
assert omni_stage.scheduler_config.max_num_batched_tokens == engine_args.get("max_num_batched_tokens") assert omni_stage.scheduler_config.max_num_batched_tokens == engine_args.get("max_num_batched_tokens")
assert omni_stage.scheduler_config.async_scheduling == engine_args.get("async_scheduling", True) assert omni_stage.scheduler_config.enable_chunked_prefill == engine_args.get("enable_chunked_prefill")
assert omni_stage.scheduler_config.async_scheduling == engine_args.get("async_scheduling")
legacy_parallel_config = engine_args.get("parallel_config") or {} legacy_parallel_config = engine_args.get("parallel_config") or {}
assert omni_stage.parallel_config.tensor_parallel_size == legacy_parallel_config.get( assert omni_stage.parallel_config.tensor_parallel_size == legacy_parallel_config.get(
"tensor_parallel_size", "tensor_parallel_size",
@@ -198,6 +204,64 @@ def test_from_pipeline_config_applies_cli_overrides_without_stage_config_runtime
assert stage1.runtime_config.num_gpus == stage1.parallel_config.world_size assert stage1.runtime_config.num_gpus == stage1.parallel_config.world_size
@pytest.mark.parametrize(
"cli_overrides",
[
{"enable_lora": True},
{"stage_0_enable_lora": True},
],
ids=["global", "stage-scoped"],
)
def test_from_pipeline_config_rejects_explicit_unowned_engine_cli_fields(cli_overrides):
with pytest.raises(ValueError, match=r"no structured config owner: enable_lora"):
_from_pipeline_key("qwen3_tts", cli_overrides=cli_overrides)
@pytest.mark.parametrize(
("engine_extras", "unowned_field"),
[
({"enable_lora": True}, "enable_lora"),
({"parallel_config": {"cfg_parallel_size": 2}}, "parallel_config.cfg_parallel_size"),
],
ids=["top-level", "nested-parallel-config"],
)
def test_from_pipeline_config_rejects_unowned_deploy_engine_extras(engine_extras, unowned_field):
pipeline = _resolve_pipeline_or_skip("qwen3_tts")
deploy = DeployConfig(stages=[StageDeployConfig(stage_id=0, engine_extras=engine_extras)])
with pytest.raises(ValueError, match=rf"no structured config owner: {unowned_field}"):
VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy)
@pytest.mark.parametrize(
("cli_overrides", "stage_id"),
[
({"kv_cache_dtype": "fp8"}, 0),
({"stage_0_kv_cache_dtype": "fp8"}, 0),
({"stage_1_kv_cache_dtype": "fp8"}, 1),
],
ids=["global", "ar-stage", "generation-stage"],
)
def test_from_pipeline_config_rejects_diffusion_only_cli_fields_for_llm_stages(cli_overrides, stage_id):
with pytest.raises(ValueError, match=rf"Stage {stage_id} .*no structured config owner: kv_cache_dtype"):
_from_pipeline_key("qwen3_tts", cli_overrides=cli_overrides)
def test_from_pipeline_config_accepts_diffusion_only_cli_fields_for_diffusion_stage():
stage = _from_pipeline_key(
"dreamzero",
deploy_config_path="dreamzero_tp1_cfg2",
cli_overrides={"kv_cache_dtype": "fp8"},
).stage_by_id(0)
assert isinstance(stage, VllmOmniDiffusionStageConfig)
assert stage.diffusion_config.diffusion_kv_cache_dtype == "fp8"
def test_stage_cli_field_selection_defers_ownership_validation_until_sources_are_merged():
assert omni_config_module._stage_cli_overrides(0, {"enable_lora": True}) == {"enable_lora": True}
def test_runtime_num_gpus_is_derived_from_parallel_world_size(): def test_runtime_num_gpus_is_derived_from_parallel_world_size():
omni_config = _from_pipeline_key("hunyuan_image3_dit") omni_config = _from_pipeline_key("hunyuan_image3_dit")
stage = omni_config.stage_by_id(0) stage = omni_config.stage_by_id(0)
@@ -380,8 +444,10 @@ def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations():
assert {f.name for f in fields(VllmOmniGenerationStageConfig)} == public_fields assert {f.name for f in fields(VllmOmniGenerationStageConfig)} == public_fields
def test_runtime_config_fields_match_rfc_runtime_scope(): def test_runtime_config_fields_match_structured_runtime_scope():
assert {f.name for f in fields(OmniStageRuntimeConfig)} == { assert {f.name for f in fields(OmniStageRuntimeConfig)} == {
"distributed_executor_backend",
"worker_cls",
"devices", "devices",
"num_replicas", "num_replicas",
"env", "env",
@@ -392,8 +458,21 @@ def test_runtime_config_fields_match_rfc_runtime_scope():
} }
def test_sub_config_fields_match_rfc_scopes(): def test_sub_config_fields_match_structured_scopes():
assert {f.name for f in fields(OmniStageModelConfig)} == { assert {f.name for f in fields(OmniStageModelConfig)} == {
"model",
"model_arch",
"revision",
"tokenizer_revision",
"code_revision",
"seed",
"logits_processors",
"trust_remote_code",
"dtype",
"attention_backend",
"moe_backend",
"hf_overrides",
"limit_mm_per_prompt",
"active_stream_window", "active_stream_window",
"duplex_max_sessions", "duplex_max_sessions",
"enable_sleep_mode", "enable_sleep_mode",
@@ -404,6 +483,7 @@ def test_sub_config_fields_match_rfc_scopes():
"task_type", "task_type",
"codec_frame_rate_hz", "codec_frame_rate_hz",
"enforce_eager", "enforce_eager",
"max_cudagraph_capture_size",
"enable_flashinfer_autotune", "enable_flashinfer_autotune",
"compilation_config", "compilation_config",
"enable_multithread_weight_load", "enable_multithread_weight_load",
@@ -416,12 +496,16 @@ def test_sub_config_fields_match_rfc_scopes():
"tokenizer_subdir", "tokenizer_subdir",
} }
assert {f.name for f in fields(OmniStageLoadConfig)} == { assert {f.name for f in fields(OmniStageLoadConfig)} == {
"tokenizer",
"download_dir",
"skip_tokenizer_init",
"load_format", "load_format",
"tokenizer_mode", "tokenizer_mode",
"config_format", "config_format",
"skip_mm_profiling", "skip_mm_profiling",
} }
assert {f.name for f in fields(OmniStageCacheConfig)} == { assert {f.name for f in fields(OmniStageCacheConfig)} == {
"kv_cache_memory_bytes",
"gpu_memory_utilization", "gpu_memory_utilization",
"enable_prefix_caching", "enable_prefix_caching",
"disable_hybrid_kv_cache_manager", "disable_hybrid_kv_cache_manager",
@@ -435,6 +519,8 @@ def test_sub_config_fields_match_rfc_scopes():
"async_scheduling", "async_scheduling",
} }
assert {f.name for f in fields(OmniStageConnectorConfig)} == { assert {f.name for f in fields(OmniStageConnectorConfig)} == {
"async_chunk",
"omni_kv_config",
"stage_connector", "stage_connector",
"output_connectors", "output_connectors",
"input_connectors", "input_connectors",
@@ -827,7 +913,7 @@ def test_from_pipeline_config_matches_build_engine_args_dict_behavior_for_repres
pipeline = _resolve_pipeline_or_skip("qwen3_tts") pipeline = _resolve_pipeline_or_skip("qwen3_tts")
legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0] legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0]
omega_stage = legacy_stage.to_omegaconf() omega_stage = legacy_stage.to_omegaconf()
legacy_engine_args = build_engine_args_dict( legacy_engine_args = build_legacy_engine_args_dict(
omega_stage, omega_stage,
model="/tmp/qwen3-tts", model="/tmp/qwen3-tts",
stage_connector_spec={"name": "SharedMemoryConnector", "extra": {}}, stage_connector_spec={"name": "SharedMemoryConnector", "extra": {}},
@@ -922,6 +1008,8 @@ def test_from_pipeline_config_normalizes_diffusion_config_aliases_from_engine_ar
def test_diffusion_config_field_classification_covers_current_fields(): def test_diffusion_config_field_classification_covers_current_fields():
from vllm_omni.diffusion.data import OmniDiffusionConfig
classified_fields = ( classified_fields = (
omni_config_module._DIFFUSION_SHARED_CONFIG_FIELDS omni_config_module._DIFFUSION_SHARED_CONFIG_FIELDS
| omni_config_module._DIFFUSION_RUNTIME_CONFIG_FIELDS | omni_config_module._DIFFUSION_RUNTIME_CONFIG_FIELDS
@@ -929,8 +1017,12 @@ def test_diffusion_config_field_classification_covers_current_fields():
) )
assert classified_fields == {f.name for f in fields(omni_config_module._DiffusionConfigProjection)} assert classified_fields == {f.name for f in fields(omni_config_module._DiffusionConfigProjection)}
assert {f.name for f in fields(OmniDiffusionConfig)} <= (
classified_fields | omni_config_module._DIFFUSION_MOVED_SHARED_FIELDS
)
assert { assert {
"enable_prompt_embed_cache", "enable_prompt_embed_cache",
"enable_session_state_manager",
"prompt_embed_cache_size", "prompt_embed_cache_size",
"diffusion_kv_cache_dtype", "diffusion_kv_cache_dtype",
} <= omni_config_module._DIFFUSION_ONLY_CONFIG_FIELDS } <= omni_config_module._DIFFUSION_ONLY_CONFIG_FIELDS
+121 -40
View File
@@ -1,4 +1,3 @@
import copy
import json import json
import os import os
import re import re
@@ -10,7 +9,7 @@ from typing import Any
import pytest import pytest
from tests.helpers.mark import hardware_marks from tests.helpers.mark import get_hardware_mark_list, hardware_marks
from tests.helpers.runtime import OmniServerParams from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import modify_stage_config from tests.helpers.stage_config import modify_stage_config
from vllm_omni.platforms import current_omni_platform from vllm_omni.platforms import current_omni_platform
@@ -447,36 +446,16 @@ def _unique_benchmark_param_id_suffixes(params_list: list[dict[str, Any]]) -> li
return unique return unique
def extract_mark_resource_label(mark_field: Any) -> str: # Device marketing-name tokens for ``get_runtime_resource_label`` only.
"""Return a filename-safe hardware label from ``mark.hardware_marks.res`` values. # Longer / more specific tokens must appear before shorter substrings
# (e.g. ``H200`` before ``H20``, ``910B4`` before ``910``).
Example: ``{"cuda": "H100"}`` -> ``"H100"``; multiple platforms join with ``-``. _RUNTIME_DEVICE_ALIASES: tuple[str, ...] = (
Prefer :func:`get_runtime_resource_label` for perf result filenames so labels
reflect the machine that actually ran the benchmark.
"""
if isinstance(mark_field, list):
for item in mark_field:
if isinstance(item, dict) and "hardware_marks" in item:
return extract_mark_resource_label(item)
return "na"
if not isinstance(mark_field, dict):
return "na"
hw = mark_field.get("hardware_marks")
if not isinstance(hw, dict):
return "na"
res = hw.get("res")
if not isinstance(res, dict) or not res:
return "na"
labels = [_safe_filename_token(value) for value in res.values()]
return "-".join(labels) if labels else "na"
_KNOWN_RUNTIME_RESOURCE_TOKENS: tuple[str, ...] = (
"H100", "H100",
"H800", "H800",
"H200", "H200",
"H20", "H20",
"B200",
"GB200",
"L40S", "L40S",
"L40", "L40",
"L4", "L4",
@@ -505,7 +484,7 @@ def _normalize_runtime_device_label(raw: str) -> str:
if not raw or not str(raw).strip(): if not raw or not str(raw).strip():
return "na" return "na"
upper = str(raw).upper() upper = str(raw).upper()
for token in _KNOWN_RUNTIME_RESOURCE_TOKENS: for token in _RUNTIME_DEVICE_ALIASES:
if token.upper() in upper: if token.upper() in upper:
return _safe_filename_token(token) return _safe_filename_token(token)
compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw)) compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw))
@@ -558,10 +537,108 @@ def resource_label_for_filename(resource_label: str | None) -> str:
return token return token
def extract_configs_resource_label(configs: list[dict[str, Any]]) -> str: def is_hardware_nested_baseline(baseline: dict[str, Any]) -> bool:
"""Return runtime hardware label for perf result filenames.""" """True for ``{"H100": {"metric": ...}, "A3": {...}}`` baselines.
del configs
return get_runtime_resource_label() Detection is structural + hardware-label allowlist (not metric-name hints):
- every top-level value is a non-empty dict (per-hardware metric map);
- every top-level key is in :func:`tests.helpers.mark.get_hardware_mark_list`
(``[hardware-resource]`` markers from ``pyproject.toml``).
Metric names under each hardware bucket are unconstrained.
Runtime device aliases (:data:`_RUNTIME_DEVICE_ALIASES`) are unrelated.
"""
if not baseline:
return False
if not all(isinstance(value, dict) and bool(value) for value in baseline.values()):
return False
allowed = {label.upper() for label in get_hardware_mark_list()}
return all((token := str(key).strip()) and token.upper() in allowed for key in baseline.keys())
def resolve_baseline_value(
baseline_raw: Any,
*,
sweep_index: int | None,
) -> Any:
"""Pick the baseline threshold for one metric at this sweep step.
Accepts a sweep-aligned list/tuple (indexed by ``sweep_index``) or a scalar.
"""
if baseline_raw is None:
return None
if isinstance(baseline_raw, dict):
raise TypeError(
"per-metric baseline dict keyed by concurrency/request-rate is not supported; "
"use a sweep-aligned list or a scalar under each hardware bucket"
)
if isinstance(baseline_raw, (list, tuple)):
if sweep_index is None:
raise ValueError("list baseline requires sweep_index")
if not (0 <= sweep_index < len(baseline_raw)):
raise IndexError(f"baseline list len={len(baseline_raw)} has no index {sweep_index}")
return baseline_raw[sweep_index]
return baseline_raw
def resolve_baseline_for_sweep(
baseline_config: dict[str, Any] | None,
*,
sweep_index: int | None = None,
) -> dict[str, Any]:
"""Resolve sweep-dependent baselines while keeping every hardware bucket.
Expects hardware-nested input only::
{"H100": {"throughput_qps": [a, b, c]}, "A3": {"throughput_qps": [x, y, z]}}
with ``sweep_index=2`` (e.g. concurrency 32) becomes::
{"H100": {"throughput_qps": c}, "A3": {"throughput_qps": z}}
Per-metric values may be a sweep-aligned list or a scalar.
"""
if not baseline_config:
return {}
if not is_hardware_nested_baseline(baseline_config):
allowed = sorted(get_hardware_mark_list())
unknown = [
str(key).strip()
for key in baseline_config.keys()
if str(key).strip().upper() not in {label.upper() for label in allowed}
]
nested_shape = all(isinstance(value, dict) and bool(value) for value in baseline_config.values())
if nested_shape and unknown:
raise ValueError(
"baseline top-level keys must be [hardware-resource] pytest markers from "
"pyproject.toml (tool.pytest.ini_options.markers). "
f"Unknown hardware label(s): {unknown!r}. "
f"Known markers: {allowed!r}. "
"Add a marker such as '\"A100: [hardware-resource] Tests that require A100 GPU\"' "
"to pyproject.toml, then retry."
)
raise ValueError(
"baseline must be hardware-nested with [hardware-resource] marker names as "
"top-level keys, e.g. "
'{"H100": {"throughput_qps": [...]}, "A3": {"custom_metric": 1.0}}. '
f"got top-level keys={list(baseline_config.keys())!r}; "
f"known markers={allowed!r}. "
"If you need a new hardware bucket, add it to pyproject.toml markers with "
"the [hardware-resource] tag."
)
return {
hardware: {
metric_name: resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
)
for metric_name, baseline_raw in metrics.items()
}
for hardware, metrics in baseline_config.items()
if isinstance(metrics, dict)
}
def run_benchmark( def run_benchmark(
@@ -572,17 +649,18 @@ def run_benchmark(
num_prompt: int, num_prompt: int,
*, *,
baseline_config: dict[str, Any] | None = None, baseline_config: dict[str, Any] | None = None,
sweep_index: int | None = None,
random_input_len: Any | None = None, random_input_len: Any | None = None,
random_output_len: Any | None = None, random_output_len: Any | None = None,
resource_label: str | None = None, resource_label: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run one ``vllm bench serve --omni`` iteration and return parsed metrics. """Run one ``vllm bench serve --omni`` iteration and return parsed metrics.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` stores the raw After ``vllm bench`` writes the JSON, ``result["baseline"]`` stores the
``baseline`` object from the test config (when ``baseline_config`` is provided), sweep-resolved baseline (when ``baseline_config`` is provided). Hardware-nested
including any hardware-nested map (e.g. ``{"H100": {...}}``). Downstream report maps keep every hardware bucket, but each metric is reduced to the value for
tooling can select a hardware bucket when needed. If the benchmark exits without this concurrency / request-rate step. If the benchmark exits without writing a
writing a result file, ``result_omni_template.json`` is used as a fallback. result file, ``result_omni_template.json`` is used as a fallback.
""" """
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S") current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
ri = _safe_filename_token(random_input_len) ri = _safe_filename_token(random_input_len)
@@ -649,8 +727,11 @@ def run_benchmark(
result = json.load(f) result = json.load(f)
if baseline_config: if baseline_config:
# Persist the JSON baseline as-is (no hardware / sweep resolution). # Keep every hardware bucket; resolve list metrics to this sweep step.
result["baseline"] = copy.deepcopy(baseline_config) result["baseline"] = resolve_baseline_for_sweep(
baseline_config,
sweep_index=sweep_index,
)
else: else:
result["baseline"] = {} result["baseline"] = {}
if random_input_len is not None: if random_input_len is not None:
@@ -21,11 +21,13 @@
"num-input-images": 0, "num-input-images": 0,
"enable-negative-prompt": false, "enable-negative-prompt": false,
"baseline": { "baseline": {
"H100": {
"throughput_qps": 0, "throughput_qps": 0,
"latency_mean": 0, "latency_mean": 0,
"peak_memory_mb_max": 0, "peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0 "peak_memory_mb_mean": 0
} }
}
}, },
"result": { "result": {
"duration": 0, "duration": 0,
@@ -46,9 +46,11 @@
"median_audio_duration_s": 0, "median_audio_duration_s": 0,
"p99_audio_duration_s": 0, "p99_audio_duration_s": 0,
"baseline": { "baseline": {
"H100": {
"mean_ttft_ms": 0, "mean_ttft_ms": 0,
"mean_audio_ttfp_ms": 0, "mean_audio_ttfp_ms": 0,
"mean_audio_rtf": 0 "mean_audio_rtf": 0
}
}, },
"random_input_len": 0, "random_input_len": 0,
"random_output_len": 0 "random_output_len": 0
+4 -2
View File
@@ -237,7 +237,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
break break
# QPS / request-rate sweep # QPS / request-rate sweep
for qps, num_prompt in zip(qps_list, num_prompt_list): for sweep_index, (qps, num_prompt) in enumerate(zip(qps_list, num_prompt_list)):
args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)] args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)]
result = run_benchmark( result = run_benchmark(
args=args, args=args,
@@ -246,6 +246,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
dataset_name=dataset_name, dataset_name=dataset_name,
num_prompt=num_prompt, num_prompt=num_prompt,
baseline_config=params.get("baseline"), baseline_config=params.get("baseline"),
sweep_index=sweep_index,
random_input_len=params.get("random_input_len"), random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"), random_output_len=params.get("random_output_len"),
resource_label=resource_label, resource_label=resource_label,
@@ -253,7 +254,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
assert_result(result, num_prompt) assert_result(result, num_prompt)
# concurrency test # concurrency test
for concurrency, num_prompt in zip(max_concurrency_list, num_prompt_list): for sweep_index, (concurrency, num_prompt) in enumerate(zip(max_concurrency_list, num_prompt_list)):
args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"] args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"]
result = run_benchmark( result = run_benchmark(
args=args, args=args,
@@ -262,6 +263,7 @@ def test_performance_benchmark(omni_server, benchmark_params):
dataset_name=dataset_name, dataset_name=dataset_name,
num_prompt=num_prompt, num_prompt=num_prompt,
baseline_config=params.get("baseline"), baseline_config=params.get("baseline"),
sweep_index=sweep_index,
random_input_len=params.get("random_input_len"), random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"), random_output_len=params.get("random_output_len"),
resource_label=resource_label, resource_label=resource_label,
@@ -23,7 +23,6 @@ of all runs from cases in that file). Bulk load without ``--test-config-file`` u
the same per-file aggregation; ``-m`` only selects which cases run. the same per-file aggregation; ``-m`` only selects which cases run.
""" """
import copy
import json import json
import os import os
import socket import socket
@@ -45,6 +44,7 @@ from tests.dfx.conftest import (
get_runtime_resource_label, get_runtime_resource_label,
hardware_json_value, hardware_json_value,
is_diffusion_perf_config, is_diffusion_perf_config,
resolve_baseline_for_sweep,
resolve_pytest_marks, resolve_pytest_marks,
resource_label_for_filename, resource_label_for_filename,
) )
@@ -350,10 +350,49 @@ def _kill_process_tree(pid: int) -> None:
def _resolve_offline_model(model: str) -> str: def _resolve_offline_model(model: str) -> str:
"""Under HF_HUB_OFFLINE, resolve a HF repo id to its local snapshot dir.""" """Resolve a model string to a local directory.
Local paths pass through unchanged. HF repo ids are resolved to their local
snapshot dir when ``HF_HUB_OFFLINE`` is set. ``repo_id/subfolder`` (e.g.
``MiniMaxAI/MiniMax-H3/FL2VA``) is resolved to the matching partition
subfolder of the snapshot, downloading only that subfolder when online.
"""
import huggingface_hub import huggingface_hub
if not model or os.path.isdir(model) or not huggingface_hub.constants.HF_HUB_OFFLINE: if not model or os.path.isdir(model):
return model
# MiniMax-H3 custom code uses relative imports across files, which breaks
# when the HF cache snapshot is symlinked (``get_class_from_dynamic_module``
# resolves ``realpath`` into the blobs dir). Mirror the accuracy test's env
# overrides so CI / local runs can point at a materialized partition. The
# repo-root key (used with ``--task-type fl2va|ref2va``) expects a
# materialized root containing the FL2VA/Ref2VA subfolders.
model_env_overrides = {
"MiniMaxAI/MiniMax-H3": "VLLM_TEST_MINIMAX_H3_MODEL",
"MiniMaxAI/MiniMax-H3/FL2VA": "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL",
"MiniMaxAI/MiniMax-H3/Ref2VA": "VLLM_TEST_MINIMAX_H3_REF2VA_MODEL",
}
env_name = model_env_overrides.get(model)
if env_name:
env_model = os.environ.get(env_name)
if env_model:
return env_model
parts = model.split("/")
if len(parts) >= 3:
repo_id = "/".join(parts[:2])
subfolder = "/".join(parts[2:])
from huggingface_hub import snapshot_download
snapshot_root = snapshot_download(
repo_id,
allow_patterns=[f"{subfolder}/**"],
local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE,
)
return str(Path(snapshot_root) / subfolder)
if not huggingface_hub.constants.HF_HUB_OFFLINE:
return model return model
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
@@ -387,6 +426,11 @@ class DiffusionServer:
def _start_server(self) -> None: def _start_server(self) -> None:
env = os.environ.copy() env = os.environ.copy()
env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" env["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
# Give each run its own writable video-storage dir. The server default
# (/tmp/storage) may be missing or not writable on shared hosts / CI.
storage_path = Path(os.environ.get("VLLM_OMNI_STORAGE_PATH", str(BENCHMARK_RESULT_DIR / "storage")))
storage_path.mkdir(parents=True, exist_ok=True)
env["VLLM_OMNI_STORAGE_PATH"] = str(storage_path)
cmd = [ cmd = [
sys.executable, sys.executable,
@@ -766,8 +810,8 @@ def run_benchmark(
completed = metrics.get("completed_requests", metrics.get("completed", 0)) completed = metrics.get("completed_requests", metrics.get("completed", 0))
failed = metrics.get("failed_requests", metrics.get("failed", 0)) failed = metrics.get("failed_requests", metrics.get("failed", 0))
# Persist the JSON baseline as-is (no hardware / sweep resolution). # Persist sweep-resolved baseline from params (already narrowed in _build_run_params).
baseline = copy.deepcopy(params.get("baseline") or {}) baseline = params.get("baseline") or {}
metrics["baseline"] = baseline metrics["baseline"] = baseline
record: dict[str, Any] = { record: dict[str, Any] = {
@@ -862,6 +906,7 @@ def _build_run_params(
params: dict[str, Any], params: dict[str, Any],
*, *,
num_prompts: int, num_prompts: int,
sweep_index: int | None = None,
request_rate: Any | None = None, request_rate: Any | None = None,
max_concurrency: Any | None = None, max_concurrency: Any | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -873,6 +918,12 @@ def _build_run_params(
run_params["request-rate"] = request_rate run_params["request-rate"] = request_rate
if max_concurrency is not None: if max_concurrency is not None:
run_params["max-concurrency"] = max_concurrency run_params["max-concurrency"] = max_concurrency
if "baseline" in params:
# Keep all hardware buckets; pick the metric value for this sweep step only.
run_params["baseline"] = resolve_baseline_for_sweep(
params.get("baseline"),
sweep_index=sweep_index,
)
return run_params return run_params
@@ -895,19 +946,20 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
sweep_runs: list[dict[str, Any]] = [] sweep_runs: list[dict[str, Any]] = []
for request_rate, num_prompts in zip(request_rate_list, num_prompt_list): for sweep_index, (request_rate, num_prompts) in enumerate(zip(request_rate_list, num_prompt_list)):
sweep_runs.append( sweep_runs.append(
{ {
"params": _build_run_params( "params": _build_run_params(
params, params,
request_rate=request_rate, request_rate=request_rate,
num_prompts=num_prompts, num_prompts=num_prompts,
sweep_index=sweep_index,
), ),
"num_prompts": num_prompts, "num_prompts": num_prompts,
} }
) )
for max_concurrency, num_prompts in zip(max_concurrency_list, num_prompt_list): for sweep_index, (max_concurrency, num_prompts) in enumerate(zip(max_concurrency_list, num_prompt_list)):
sweep_runs.append( sweep_runs.append(
{ {
"params": _build_run_params( "params": _build_run_params(
@@ -915,6 +967,7 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
max_concurrency=max_concurrency, max_concurrency=max_concurrency,
num_prompts=num_prompts, num_prompts=num_prompts,
request_rate="inf", request_rate="inf",
sweep_index=sweep_index,
), ),
"num_prompts": num_prompts, "num_prompts": num_prompts,
} }
@@ -927,6 +980,7 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
"params": _build_run_params( "params": _build_run_params(
params, params,
num_prompts=default_num_prompts, num_prompts=default_num_prompts,
sweep_index=0,
), ),
"num_prompts": default_num_prompts, "num_prompts": default_num_prompts,
} }
@@ -25,8 +25,8 @@
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"H100": { "H100": {
"throughput_qps": {"1": 0.0303, "2": 0.0302, "4": 0.0302}, "throughput_qps": [0.0303, 0.0302, 0.0302],
"latency_mean": {"1": 32.6, "2": 62.2, "4": 111.7}, "latency_mean": [32.6, 62.2, 111.7],
"peak_memory_mb_max": 40500, "peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500 "peak_memory_mb_mean": 40500
} }
@@ -25,8 +25,8 @@
"enable-negative-prompt": true, "enable-negative-prompt": true,
"baseline": { "baseline": {
"H100": { "H100": {
"throughput_qps": {"1": 0.0549, "2": 0.0547, "4": 0.0549}, "throughput_qps": [0.0549, 0.0547, 0.0549],
"latency_mean": {"1": 18.0, "2": 34.3, "4": 61.4}, "latency_mean": [18.0, 34.3, 61.4],
"peak_memory_mb_max": 40500, "peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500 "peak_memory_mb_mean": 40500
} }
@@ -134,7 +134,7 @@
"max-concurrency": 16, "max-concurrency": 16,
"num-prompts": 96, "num-prompts": 96,
"baseline": { "baseline": {
"H100": { "H200": {
"throughput_qps": 0.1874, "throughput_qps": 0.1874,
"latency_p99": 101.852, "latency_p99": 101.852,
"latency_mean": 79.0385, "latency_mean": 79.0385,
@@ -38,7 +38,7 @@
"num-prompts": 10, "num-prompts": 10,
"max-concurrency": 1, "max-concurrency": 1,
"baseline": { "baseline": {
"H100": { "H200": {
"throughput_qps": 0.21, "throughput_qps": 0.21,
"latency_p99": 4.7469, "latency_p99": 4.7469,
"peak_memory_mb_max": 101100, "peak_memory_mb_max": 101100,
@@ -38,7 +38,7 @@
"num-prompts": 10, "num-prompts": 10,
"max-concurrency": 1, "max-concurrency": 1,
"baseline": { "baseline": {
"H100": { "H200": {
"throughput_qps": 0.20, "throughput_qps": 0.20,
"latency_p99": 5.1025, "latency_p99": 5.1025,
"peak_memory_mb_max": 97402, "peak_memory_mb_max": 97402,
@@ -37,7 +37,7 @@
"num-prompts": 10, "num-prompts": 10,
"max-concurrency": 1, "max-concurrency": 1,
"baseline": { "baseline": {
"H100": { "H200": {
"throughput_qps": 0.213, "throughput_qps": 0.213,
"latency_mean": 4.6953, "latency_mean": 4.6953,
"latency_p99": 4.8601, "latency_p99": 4.8601,
@@ -0,0 +1,295 @@
[
{
"test_name": "test_minimax_h3_t2v_4gpu",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 4
}
},
"full_model",
"diffusion"
],
"description": "MiniMax-H3 T2VA text-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)",
"server_type": "vllm-omni",
"benchmark_endpoint": "/v1/videos",
"server_params": {
"model": "MiniMaxAI/MiniMax-H3",
"serve_args": {
"trust-remote-code": true,
"task-type": "fl2va",
"num-gpus": 4,
"usp": 4,
"ring": 1,
"use-hsdp": true,
"hsdp-shard-size": 4,
"text-encoder-tp-size": 4,
"vae-patch-parallel-size": 4,
"vae-parallel-mode": "tile",
"vae-use-tiling": true,
"diffusion-attention-backend": "FLASH_ATTN",
"stage-init-timeout": 1800,
"init-timeout": 1800,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1344x768_frames209_steps8",
"dataset": "random",
"task": "t2v",
"num-prompts": 3,
"max-concurrency": 1,
"warmup-requests": 1,
"warmup-concurrency": 1,
"seed": 42,
"random-request-config": [
{
"width": 1344,
"height": 768,
"prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.",
"num_inference_steps": 8,
"num_frames": 209,
"fps": 24,
"weight": 1
}
],
"extra-body": {
"flow_shift": 12.0,
"extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}"
},
"baseline": {
"H100": {
"throughput_qps": 0.0211,
"latency_mean": 38.3252,
"peak_memory_mb_mean": 54219.6
}
}
}
]
},
{
"test_name": "test_minimax_h3_ti2v_4gpu",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 4
}
},
"full_model",
"diffusion"
],
"description": "MiniMax-H3 FL2VA first-frame image-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)",
"server_type": "vllm-omni",
"benchmark_endpoint": "/v1/videos",
"server_params": {
"model": "MiniMaxAI/MiniMax-H3",
"serve_args": {
"trust-remote-code": true,
"task-type": "fl2va",
"num-gpus": 4,
"usp": 4,
"ring": 1,
"use-hsdp": true,
"hsdp-shard-size": 4,
"text-encoder-tp-size": 4,
"vae-patch-parallel-size": 4,
"vae-parallel-mode": "tile",
"vae-use-tiling": true,
"diffusion-attention-backend": "FLASH_ATTN",
"stage-init-timeout": 1800,
"init-timeout": 1800,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1344x768_frames209_steps8",
"dataset": "random",
"task": "ti2v",
"num-prompts": 3,
"max-concurrency": 1,
"warmup-requests": 1,
"warmup-concurrency": 1,
"seed": 42,
"random-request-config": [
{
"width": 1344,
"height": 768,
"prompt": "Continue the scene: the camera slowly pushes in on the subject as the background city lights drift and blur, with the actor singing softly and emotionally.",
"num_inference_steps": 8,
"num_frames": 209,
"fps": 24,
"weight": 1
}
],
"extra-body": {
"flow_shift": 12.0,
"extra_params": "{\"task\":\"fl2va\",\"duration\":8.7,\"audio_flow_shift\":3.0}"
},
"num-input-images": 1,
"baseline": {
"H100": {
"throughput_qps": 0.0212,
"latency_mean": 38.1385,
"peak_memory_mb_mean": 54622.8
}
}
}
]
},
{
"test_name": "test_minimax_h3_v2v_4gpu",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 4
}
},
"full_model",
"diffusion"
],
"description": "MiniMax-H3 Ref2VA video-reference-to-video+audio on 4 GPUs (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)",
"server_type": "vllm-omni",
"benchmark_endpoint": "/v1/videos",
"server_params": {
"model": "MiniMaxAI/MiniMax-H3",
"serve_args": {
"trust-remote-code": true,
"task-type": "ref2va",
"num-gpus": 4,
"usp": 4,
"ring": 1,
"use-hsdp": true,
"hsdp-shard-size": 4,
"text-encoder-tp-size": 4,
"vae-patch-parallel-size": 4,
"vae-parallel-mode": "tile",
"vae-use-tiling": true,
"diffusion-attention-backend": "FLASH_ATTN",
"stage-init-timeout": 1800,
"init-timeout": 1800,
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "1344x768_frames209_steps8",
"dataset": "random",
"task": "v2v",
"num-prompts": 3,
"max-concurrency": 1,
"warmup-requests": 1,
"warmup-concurrency": 1,
"seed": 42,
"random-request-config": [
{
"width": 1344,
"height": 768,
"prompt": "Restyle the reference footage into a cinematic night scene with neon reflections, keeping the same camera motion and subject.",
"num_inference_steps": 8,
"num_frames": 209,
"fps": 24,
"weight": 1
}
],
"extra-body": {
"flow_shift": 12.0,
"extra_params": "{\"task\":\"ref2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}"
},
"baseline": {
"H100": {
"throughput_qps": 0.0077,
"latency_mean": 105.7508,
"peak_memory_mb_mean": 55180.8
}
}
}
]
},
{
"test_name": "test_minimax_h3_t2v_dlo_4gpu",
"mark": [
{
"hardware_marks": {
"res": {
"cuda": "H100"
},
"num_cards": 4
}
},
"full_model",
"diffusion"
],
"description": "MiniMax-H3 T2VA with 4-card distributed layerwise offload (DLO), 8 denoise steps (USP4 + HSDP4, encoder-TP4, VAE-PP4 tile)",
"server_type": "vllm-omni",
"benchmark_endpoint": "/v1/videos",
"server_params": {
"model": "MiniMaxAI/MiniMax-H3",
"serve_args": {
"trust-remote-code": true,
"task-type": "fl2va",
"num-gpus": 4,
"usp": 4,
"ring": 1,
"use-hsdp": true,
"hsdp-shard-size": 4,
"text-encoder-tp-size": 4,
"vae-patch-parallel-size": 4,
"vae-parallel-mode": "tile",
"vae-use-tiling": true,
"diffusion-attention-backend": "FLASH_ATTN",
"stage-init-timeout": 1800,
"init-timeout": 1800,
"enable-diffusion-pipeline-profiler": true,
"enable-distributed-layerwise-offload": true,
"dlo-no-use-allgather": true,
"dlo-resident-layers": 8,
"enforce-eager": true
}
},
"benchmark_params": [
{
"name": "1344x768_frames209_steps8",
"dataset": "random",
"task": "t2v",
"num-prompts": 3,
"max-concurrency": 1,
"warmup-requests": 1,
"warmup-concurrency": 1,
"seed": 42,
"random-request-config": [
{
"width": 1344,
"height": 768,
"prompt": "Cinematic drone shot pushing through misty mountain ridges at sunrise, golden light catching the clouds, slow and smooth.",
"num_inference_steps": 8,
"num_frames": 209,
"fps": 24,
"weight": 1
}
],
"extra-body": {
"flow_shift": 12.0,
"extra_params": "{\"task\":\"t2va\",\"duration\":8.7,\"aspect_ratio\":\"16:9\",\"audio_flow_shift\":3.0}"
},
"baseline": {
"H100": {
"throughput_qps": 0.0212,
"latency_mean": 38.2089,
"peak_memory_mb_mean": 36218.4
}
}
}
]
}
]
+184 -35
View File
@@ -17,7 +17,7 @@ def test_task_excluded_from_cli_args():
"backend": "openai-audio-speech", "backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech", "endpoint": "/v1/audio/speech",
"percentile-metrics": "audio_rtf,audio_ttfp", "percentile-metrics": "audio_rtf,audio_ttfp",
"baseline": {"mean_audio_rtf": [0.5]}, "baseline": {"H100": {"mean_audio_rtf": [0.5]}},
} }
exclude_keys = { exclude_keys = {
"request_rate", "request_rate",
@@ -123,35 +123,6 @@ def test_resolve_pytest_marks_rejects_legacy_object_format():
) )
def test_extract_mark_resource_label():
from tests.dfx.conftest import extract_mark_resource_label
assert extract_mark_resource_label(None) == "na"
assert (
extract_mark_resource_label(
[
{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}},
"full_model",
]
)
== "H100"
)
assert (
extract_mark_resource_label(
[
{
"hardware_marks": {
"res": {"cuda": "H100", "rocm": "MI325"},
"num_cards": 2,
}
},
"diffusion",
]
)
== "H100-MI325"
)
def test_resource_label_for_filename(): def test_resource_label_for_filename():
from tests.dfx.conftest import resource_label_for_filename from tests.dfx.conftest import resource_label_for_filename
@@ -169,16 +140,14 @@ def test_hardware_json_value():
assert hardware_json_value(None) == "" assert hardware_json_value(None) == ""
def test_extract_configs_resource_label(monkeypatch): def test_get_runtime_resource_label(monkeypatch):
from tests.dfx.conftest import extract_configs_resource_label, get_runtime_resource_label from tests.dfx.conftest import get_runtime_resource_label
monkeypatch.setattr( monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name", "tests.dfx.conftest._read_runtime_device_name",
lambda *, device_id=0: "NVIDIA H100 80GB HBM3", lambda *, device_id=0: "NVIDIA H100 80GB HBM3",
) )
get_runtime_resource_label(refresh=True) assert get_runtime_resource_label(refresh=True) == "H100"
assert extract_configs_resource_label([]) == "H100"
get_runtime_resource_label(refresh=True)
monkeypatch.setattr( monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name", "tests.dfx.conftest._read_runtime_device_name",
lambda *, device_id=0: "Ascend910B2", lambda *, device_id=0: "Ascend910B2",
@@ -336,3 +305,183 @@ def test_paired_omni_benchmark_reuses_server_and_preserves_case_metadata(tmp_pat
("start", tts_server), ("start", tts_server),
("stop", tts_server), ("stop", tts_server),
] ]
def test_is_hardware_nested_baseline():
from tests.dfx.conftest import (
_RUNTIME_DEVICE_ALIASES,
is_hardware_nested_baseline,
)
from tests.helpers.mark import get_hardware_mark_list
hardware_marks = get_hardware_mark_list()
assert hardware_marks
assert {"H100", "L4", "A3", "MI325", "B200"} <= hardware_marks
assert "cuda" not in hardware_marks
assert "full_model" not in hardware_marks
# Runtime aliases are a full static list (independent of baseline allowlist).
assert "H100" in _RUNTIME_DEVICE_ALIASES
assert "A100" in _RUNTIME_DEVICE_ALIASES
assert is_hardware_nested_baseline(
{
"H100": {"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]},
"A3": {"mean_ttft_ms": [0.9, 1.8], "mean_e2el_ms": [9.0, 18.0]},
}
)
# Custom metric names under a known hardware label are allowed.
assert is_hardware_nested_baseline({"H100": {"custom_stage_ms": 12.0, "foo_bar": [1.0, 2.0]}})
assert is_hardware_nested_baseline({"A3": {"request_throughput": 0.5}})
# Flat metric maps / concurrency-keyed maps are not hardware-nested.
assert not is_hardware_nested_baseline({"mean_ttft_ms": [1.0, 2.0], "mean_e2el_ms": [10.0, 20.0]})
assert not is_hardware_nested_baseline({"mean_ttft_ms": {"1": 1.0, "32": 2.0}})
assert not is_hardware_nested_baseline({})
# Alias-only labels (not [hardware-resource] markers) are rejected for baselines.
assert not is_hardware_nested_baseline({"A100": {"throughput_qps": 1.0}})
# Unknown top-level hardware label is rejected.
assert not is_hardware_nested_baseline({"UnknownGPU": {"throughput_qps": 1.0}})
# Empty per-hardware metric map is rejected.
assert not is_hardware_nested_baseline({"H100": {}})
def test_resolve_baseline_for_sweep_keeps_all_hardware_for_one_concurrency():
from tests.dfx.conftest import resolve_baseline_for_sweep
baseline = {
"H100": {
"mean_ttft_ms": [96.4, 140.8, 271.9, 362.3, 507.8],
"mean_e2el_ms": [18507.0, 28365.0, 31907.0, 48161.0, 72630.0],
},
"A3": {
"mean_ttft_ms": [90.0, 130.0, 250.0, 340.0, 480.0],
"mean_e2el_ms": [17000.0, 26000.0, 30000.0, 45000.0, 70000.0],
},
}
# max_concurrency=[1,4,8,16,32] -> index 4 is concurrency 32
got = resolve_baseline_for_sweep(baseline, sweep_index=4)
assert got == {
"H100": {"mean_ttft_ms": 507.8, "mean_e2el_ms": 72630.0},
"A3": {"mean_ttft_ms": 480.0, "mean_e2el_ms": 70000.0},
}
# First sweep step keeps both hardware buckets too.
got0 = resolve_baseline_for_sweep(baseline, sweep_index=0)
assert set(got0) == {"H100", "A3"}
assert got0["H100"]["mean_ttft_ms"] == 96.4
assert got0["A3"]["mean_ttft_ms"] == 90.0
def test_resolve_baseline_for_sweep_rejects_flat_baseline():
from tests.dfx.conftest import resolve_baseline_for_sweep
with pytest.raises(ValueError, match=r"hardware-nested.*pyproject\.toml"):
resolve_baseline_for_sweep(
{"throughput_qps": [0.4, 0.6], "latency_mean": [1.0, 2.0]},
sweep_index=1,
)
def test_resolve_baseline_for_sweep_rejects_unknown_hardware_label():
from tests.dfx.conftest import resolve_baseline_for_sweep
with pytest.raises(ValueError, match=r"Unknown hardware label\(s\): \['A100'\].*pyproject\.toml"):
resolve_baseline_for_sweep(
{"A100": {"throughput_qps": 1.0}},
sweep_index=0,
)
def test_resolve_baseline_for_sweep_supports_list_and_scalar_under_hardware():
from tests.dfx.conftest import resolve_baseline_for_sweep
# Sweep-aligned lists under each hardware bucket (canonical form).
listed = {
"H100": {"throughput_qps": [0.4, 0.6, 0.8], "latency_mean": [1.0, 2.0, 3.0]},
"A3": {"throughput_qps": [0.5, 0.7, 0.9], "latency_mean": [0.9, 1.8, 2.7]},
}
assert resolve_baseline_for_sweep(listed, sweep_index=1) == {
"H100": {"throughput_qps": 0.6, "latency_mean": 2.0},
"A3": {"throughput_qps": 0.7, "latency_mean": 1.8},
}
# Scalars under hardware stay as-is (single-concurrency cases).
scalar = {"H100": {"throughput_qps": 0.5}}
assert resolve_baseline_for_sweep(scalar, sweep_index=0) == {"H100": {"throughput_qps": 0.5}}
# Custom metric names are preserved.
custom = {"H100": {"custom_stage_ms": [10.0, 20.0]}}
assert resolve_baseline_for_sweep(custom, sweep_index=1) == {"H100": {"custom_stage_ms": 20.0}}
assert resolve_baseline_for_sweep(None) == {}
assert resolve_baseline_for_sweep({}) == {}
def test_resolve_baseline_value_errors():
from tests.dfx.conftest import resolve_baseline_value
with pytest.raises(ValueError, match="sweep_index"):
resolve_baseline_value([1.0, 2.0], sweep_index=None)
with pytest.raises(IndexError):
resolve_baseline_value([1.0], sweep_index=1)
with pytest.raises(TypeError, match="not supported"):
resolve_baseline_value({"1": 0.4}, sweep_index=0)
def test_diffusion_build_run_params_resolves_baseline_per_sweep(tmp_path, monkeypatch):
"""``_build_run_params`` / ``_iter_sweep_runs`` narrow list baselines per concurrency."""
import importlib
import sys
cfg = tmp_path / "mini_diffusion_perf.json"
cfg.write_text(
json.dumps(
[
{
"test_name": "test_mini",
"server_type": "vllm-omni",
"mark": [
{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}},
"diffusion",
"full_model",
],
"server_params": {"model": "m/mini"},
"benchmark_params": [{"name": "p0", "num-prompts": 1, "max-concurrency": 1}],
}
]
),
encoding="utf-8",
)
monkeypatch.setattr(sys, "argv", ["pytest", "--test-config-file", str(cfg)])
sys.modules.pop("tests.dfx.perf.scripts.run_diffusion_benchmark", None)
from tests.dfx.perf.scripts import run_diffusion_benchmark as rdb
importlib.reload(rdb)
params = {
"name": "c_sweep",
"dataset": "random",
"task": "t2i",
"num-prompts": [8, 16, 32],
"max-concurrency": [1, 8, 32],
"baseline": {
"H100": {"throughput_qps": [0.1, 0.2, 0.3], "latency_mean": [10.0, 20.0, 30.0]},
"A3": {"throughput_qps": [0.11, 0.22, 0.33], "latency_mean": [9.0, 19.0, 29.0]},
},
}
run32 = rdb._build_run_params(
params,
num_prompts=32,
max_concurrency=32,
request_rate="inf",
sweep_index=2,
)
assert run32["max-concurrency"] == 32
assert run32["baseline"] == {
"H100": {"throughput_qps": 0.3, "latency_mean": 30.0},
"A3": {"throughput_qps": 0.33, "latency_mean": 29.0},
}
sweeps = rdb._iter_sweep_runs(params)
assert len(sweeps) == 3
assert sweeps[2]["params"]["max-concurrency"] == 32
assert sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"] == 0.3
assert "A3" in sweeps[2]["params"]["baseline"]
assert not isinstance(sweeps[2]["params"]["baseline"]["H100"]["throughput_qps"], list)
@@ -90,6 +90,35 @@ class TestAttentionSpec:
with pytest.raises(ValueError, match=match): with pytest.raises(ValueError, match=match):
AttentionSpec(**spec) AttentionSpec(**spec)
def test_block_sparse_defaults_applied_when_backend_selected(self):
spec = AttentionSpec(backend="RAINFUSION_ATTN")
assert spec.block_sparse.sparsity == 0.8
assert spec.backend_kwargs() == {"sparsity": 0.8, "start_step": 0}
def test_block_sparse_skip_layers_selector_expanded(self):
spec = AttentionSpec(
backend="RAINFUSION_ATTN",
block_sparse={"sparsity": 0.9, "start_step": 12, "skip_layers": "0-2,38"},
)
assert spec.block_sparse.skip_layer_indices == {0, 1, 2, 38}
assert spec.backend_kwargs() == {
"sparsity": 0.9,
"start_step": 12,
"skip_layers": [0, 1, 2, 38],
}
def test_block_sparse_rejected_on_dense_backend(self):
with pytest.raises(ValueError, match="block_sparse is only supported by"):
AttentionSpec(backend="FLASH_ATTN", block_sparse={"sparsity": 0.8})
@pytest.mark.parametrize(
"block_sparse",
[{"sparsity": 1.5}, {"start_step": -1}],
)
def test_block_sparse_invalid_values(self, block_sparse):
with pytest.raises(ValueError):
AttentionSpec(backend="RAINFUSION_ATTN", block_sparse=block_sparse)
class TestAttentionConfig: class TestAttentionConfig:
def test_empty_config(self): def test_empty_config(self):
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Geometry contract RAINFUSION_ATTN enforces before handing a forward to rf_v2.
The grids here are the ones MiniMax-H3 FL2VA actually produces for an 8.7s clip:
1280x768 gives a 62x24x40 latent grid whose 59520 video rows land on the 128-row
kernel block, and 1344x768 gives 62x24x42 whose 62496 rows straddle it. Only the
first can run sparse: rf_v2 tiles the sequence as a whole but pools its block
mask from the video and prefix segments separately, so the two only agree on the
block count when the video segment ends on a boundary.
"""
import dataclasses
import pytest
import torch
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, VideoTokenLayout
from vllm_omni.diffusion.attention.backends.rainfusion_attn import (
_BLOCK_SIZE,
RainFusionAttentionImpl,
RainFusionPlan,
)
from vllm_omni.platforms import current_omni_platform
PREFIX_ROWS = 710 # 14 text rows + 696 audio rows
ALIGNED_GRID = (62, 24, 40) # 1280x768 -> 59520 video rows, 465 blocks
MISALIGNED_GRID = (62, 24, 42) # 1344x768 -> 62496 video rows, 488 blocks + 32 rows
def make_impl(**backend_kwargs):
return RainFusionAttentionImpl(
num_heads=8,
head_size=128,
softmax_scale=128**-0.5,
prefix="transformer_blocks.0.attn",
qkv_layout="BSND",
backend_kwargs={"sparsity": 0.8, **backend_kwargs},
)
def make_metadata(grid, prefix_len=PREFIX_ROWS, max_seqlen_q=None):
video_rows = grid[0] * grid[1] * grid[2]
return AttentionMetadata(
extra={"max_seqlen_q": prefix_len + video_rows if max_seqlen_q is None else max_seqlen_q},
video_layout=VideoTokenLayout(prefix_len=prefix_len, latent_grid=grid),
)
def test_block_aligned_video_segment_runs_sparse():
plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID))
assert plan is not None
assert plan.prefix_len == PREFIX_ROWS
assert plan.used_len == PREFIX_ROWS + 59520
assert plan.latent_shape == list(ALIGNED_GRID)
def test_misaligned_video_segment_stays_dense():
# Realigning the sequence would mean padding it, and rf_v2 ignores attn_mask:
# pad keys would take a share of every real query's softmax denominator.
assert make_impl()._resolve_plan(make_metadata(MISALIGNED_GRID)) is None
@pytest.mark.parametrize("prefix_len", [1, 127, _BLOCK_SIZE, 710, 900])
def test_prefix_length_does_not_affect_alignment(prefix_len):
# Only the video segment has to land on a block boundary; rf_v2 pools the
# prefix separately and keeps every one of its blocks.
plan = make_impl()._resolve_plan(make_metadata(ALIGNED_GRID, prefix_len=prefix_len))
assert plan is not None
assert plan.prefix_len == prefix_len
def test_sparsity_zero_never_resolves_a_plan():
assert make_impl(sparsity=0.0)._resolve_plan(make_metadata(ALIGNED_GRID)) is None
def test_missing_video_layout_falls_back_to_dense():
assert make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 60230})) is None
def test_missing_max_seqlen_falls_back_to_dense():
metadata = AttentionMetadata(
video_layout=VideoTokenLayout(prefix_len=PREFIX_ROWS, latent_grid=ALIGNED_GRID),
)
assert make_impl()._resolve_plan(metadata) is None
def test_video_segment_must_be_the_tail_of_packed_document_zero():
metadata = make_metadata(ALIGNED_GRID, max_seqlen_q=PREFIX_ROWS + 59520 + 128)
assert make_impl()._resolve_plan(metadata) is None
@pytest.mark.parametrize("grid", [(4, 24, 40), (1, 24, 40)])
def test_short_video_stays_dense(grid):
assert make_impl()._resolve_plan(make_metadata(grid)) is None
@pytest.mark.parametrize("qkv_layout", ["BNSD", "BSH"])
def test_explicitly_wrong_layout_is_rejected(qkv_layout):
with pytest.raises(ValueError, match="BSND"):
RainFusionAttentionImpl(
num_heads=8,
head_size=128,
softmax_scale=128**-0.5,
qkv_layout=qkv_layout,
backend_kwargs={"sparsity": 0.8},
)
def test_undeclared_layout_stays_dense():
# An undeclared layout is not an error -- the layer keeps working, just
# densely, and the fallback sees the same absent layout plain FLASH_ATTN would.
impl = RainFusionAttentionImpl(
num_heads=8,
head_size=128,
softmax_scale=128**-0.5,
prefix="transformer_blocks.0.attn",
backend_kwargs={"sparsity": 0.8},
)
assert impl._resolve_plan(make_metadata(ALIGNED_GRID)) is None
assert impl.dense_fallback.qkv_layout is None
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="rf_v2 runs on Ascend NPU only.")
def test_fully_populated_mask_reproduces_dense_attention():
"""At sparsity=0 every key block is kept, so rf_v2 must match dense attention.
This is the guard on the geometry the backend hands the kernel: a wrong
prefix length or latent grid still produces plausible output under sparse
selection, but shows up here as a mismatch against SDPA.
"""
torch.manual_seed(0)
prefix_len, grid = 384, (4, 16, 64) # 4096 video rows = 32 blocks
video_len = grid[0] * grid[1] * grid[2]
used = prefix_len + video_len
heads, head_dim = 4, 128
q, k, v = (torch.randn(1, used, heads, head_dim, dtype=torch.bfloat16, device="npu") for _ in range(3))
impl = make_impl()
impl.rainfusion = dataclasses.replace(impl.rainfusion, sparsity=0.0)
plan = RainFusionPlan(prefix_len=prefix_len, used_len=used, latent_shape=list(grid))
out = impl._forward_sparse_npu(q, k, v, plan)
reference = torch.nn.functional.scaled_dot_product_attention(
q.transpose(1, 2),
k.transpose(1, 2),
v.transpose(1, 2),
scale=impl.softmax_scale,
).transpose(1, 2)
error = (out.float() - reference.float()).abs().mean() / reference.float().abs().mean()
assert error < 2e-3, f"mean relative error {error:.4%} against dense attention"
@@ -8,6 +8,7 @@ import torch
import torch.nn.functional as F import torch.nn.functional as F
from vllm_omni.diffusion.attention.backends import trtllm_attn as tg from vllm_omni.diffusion.attention.backends import trtllm_attn as tg
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.backends.trtllm_attn import TrtllmAttentionImpl from vllm_omni.diffusion.attention.backends.trtllm_attn import TrtllmAttentionImpl
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda] pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda]
@@ -142,6 +143,9 @@ def test_denoise_progress_mixin(monkeypatch):
_Pipe().record_denoise_step(4) _Pipe().record_denoise_step(4)
assert ctx.denoise_step_idx == 4 and ctx.denoise_timestep == pytest.approx(0.25) assert ctx.denoise_step_idx == 4 and ctx.denoise_timestep == pytest.approx(0.25)
_Pipe().record_denoise_step(5, normalized_timestep=0.7)
assert ctx.denoise_step_idx == 5 and ctx.denoise_timestep == pytest.approx(0.7)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bk, field", "bk, field",
@@ -187,6 +191,7 @@ def test_mask_free_allowlist_gates_trtllm_default():
assert meta("WanPipeline").attention_mask_free is True assert meta("WanPipeline").attention_mask_free is True
assert meta("WanVACEPipeline").attention_mask_free is True assert meta("WanVACEPipeline").attention_mask_free is True
assert meta("MiniMaxH3Pipeline").attention_mask_free is True
assert meta("FluxPipeline").attention_mask_free is False assert meta("FluxPipeline").attention_mask_free is False
assert meta(None).attention_mask_free is False assert meta(None).attention_mask_free is False
@@ -204,6 +209,91 @@ def test_masked_layer_raises():
_impl().forward_cuda(q, k, v, _Meta()) _impl().forward_cuda(q, k, v, _Meta())
def test_packed_metadata_trims_padding_mask(monkeypatch):
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
mask = torch.tensor([[True, True, True, True, True, True, False, False]])
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
metadata = AttentionMetadata(
attn_mask=mask,
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
},
)
captured = {}
def fake_attention(**kwargs):
captured.update(kwargs)
return torch.zeros_like(kwargs["query"])
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
monkeypatch.setattr(tg, "trtllm_ragged_attention_deepseek", fake_attention)
monkeypatch.setattr(TrtllmAttentionImpl, "_get_workspace", classmethod(lambda cls, device: torch.empty(0)))
out = _impl().forward_cuda(q, k, v, metadata)
assert out.shape == q.shape
assert captured["query"].shape[0] == 6
assert captured["key"].shape[0] == 6
assert captured["value"].shape[0] == 6
assert captured["batch_size"] == 1
assert captured["seq_lens"].tolist() == [6]
assert captured["cum_seq_lens_q"].tolist() == [0, 6]
assert captured["cum_seq_lens_kv"].tolist() == [0, 6]
assert captured["max_q_len"] == 6
assert captured["max_kv_len"] == 6
assert torch.count_nonzero(out[:, 6:]) == 0
def test_packed_metadata_rejects_non_prefix_mask(monkeypatch):
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
cu_seqlens = torch.tensor([0, 6, 8], dtype=torch.int32)
metadata = AttentionMetadata(
attn_mask=torch.tensor([[True, True, False, True, True, True, False, False]]),
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
},
)
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
with pytest.raises(ValueError, match="prefix-valid"):
_impl().forward_cuda(q, k, v, metadata)
def test_packed_metadata_must_be_complete():
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
metadata = AttentionMetadata(extra={"cu_seqlens_q": torch.tensor([0, s], dtype=torch.int32)})
with pytest.raises(ValueError, match="Incomplete packed TRTLLM attention metadata"):
_impl().forward_cuda(q, k, v, metadata)
def test_packed_metadata_must_cover_inputs(monkeypatch):
b, s, h, d = 1, 8, 8, 128
q, k, v = (torch.zeros(b, s, h, d) for _ in range(3))
cu_seqlens = torch.tensor([0, 6], dtype=torch.int32)
metadata = AttentionMetadata(
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": 6,
"max_seqlen_k": 6,
}
)
monkeypatch.setattr(tg, "HAS_FLASHINFER", True)
with pytest.raises(ValueError, match="must cover all Q/K/V tokens"):
_impl().forward_cuda(q, k, v, metadata)
def test_propagate_calibration_045_schema(monkeypatch): def test_propagate_calibration_045_schema(monkeypatch):
import types import types
@@ -295,6 +385,30 @@ def test_bf16_dense_matches_sdpa():
assert rel < 0.01, f"BF16 dense rel err {rel:.4f} too high" assert rel < 0.01, f"BF16 dense rel err {rel:.4f} too high"
@requires_trtllm_attn
def test_bf16_packed_padding_matches_sdpa():
torch.manual_seed(0)
b, s, used, h, d = 1, 256, 192, 8, 128
scale = 1.0 / math.sqrt(d)
q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3))
cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda")
metadata = AttentionMetadata(
attn_mask=torch.arange(s, device="cuda")[None] < used,
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": used,
"max_seqlen_k": used,
},
)
out = _impl().forward_cuda(q, k, v, metadata).float()
ref_used = _sdpa_ref(q[:, :used], k[:, :used], v[:, :used], scale)
used_rel = (out[:, :used] - ref_used).abs().mean() / ref_used.abs().mean()
assert used_rel < 0.01, f"BF16 packed valid-token rel err {used_rel:.4f} too high"
assert torch.count_nonzero(out[:, used:]) == 0
requires_sage = pytest.mark.skipif( requires_sage = pytest.mark.skipif(
not (_has_trtllm_attn() and tg._sage_quantize_fn() is not None), not (_has_trtllm_attn() and tg._sage_quantize_fn() is not None),
reason="requires Blackwell SM100+ GPU with flashinfer >= 0.6.16rc1 (trtllm_sage_attention_quantize)", reason="requires Blackwell SM100+ GPU with flashinfer >= 0.6.16rc1 (trtllm_sage_attention_quantize)",
@@ -321,6 +435,47 @@ def test_sage_quant_matches_sdpa(dtype_qk):
assert rel < 0.1, f"SAGE({dtype_qk}) rel err {rel:.4f} too high" assert rel < 0.1, f"SAGE({dtype_qk}) rel err {rel:.4f} too high"
@requires_sage
def test_sage_packed_non_aligned_length_matches_sdpa():
torch.manual_seed(0)
b, s, used, h, d = 1, 208, 194, 8, 128
scale = 1.0 / math.sqrt(d)
q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3))
cu_seqlens = torch.tensor([0, used, s], dtype=torch.int32, device="cuda")
metadata = AttentionMetadata(
attn_mask=torch.arange(s, device="cuda")[None] < used,
extra={
"cu_seqlens_q": cu_seqlens,
"cu_seqlens_k": cu_seqlens,
"max_seqlen_q": used,
"max_seqlen_k": used,
},
)
out = _impl(quant={"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16}).forward_cuda(q, k, v, metadata)
assert torch.isfinite(out).all()
ref = _sdpa_ref(q[:, :used], k[:, :used], v[:, :used], scale)
rel = (out[:, :used].float() - ref).abs().mean() / ref.abs().mean()
assert rel < 0.2, f"SAGE packed valid-token rel err {rel:.4f} too high"
assert torch.count_nonzero(out[:, used:]) == 0
@requires_sage
def test_sage_short_sequence_uses_dense_kernel():
torch.manual_seed(0)
b, s, h, d = 1, 14, 8, 128
scale = 1.0 / math.sqrt(d)
q, k, v = (torch.randn(b, s, h, d, device="cuda", dtype=torch.bfloat16) for _ in range(3))
impl = _impl(quant={"dtype_qk": "fp8_e4m3", "q_block_size": 1, "k_block_size": 16})
impl._sage_quantize_fn = lambda *args, **kwargs: pytest.fail("short sequence must not use SAGE")
out = impl.forward_cuda(q, k, v)
ref = _sdpa_ref(q, k, v, scale)
rel = (out.float() - ref).abs().mean() / ref.abs().mean()
assert torch.isfinite(out).all()
assert rel < 0.01, f"SAGE short-sequence dense fallback rel err {rel:.4f} too high"
@requires_trtllm_attn @requires_trtllm_attn
def test_skip_tiny_threshold_matches_dense(): def test_skip_tiny_threshold_matches_dense():
torch.manual_seed(0) torch.manual_seed(0)
+31 -5
View File
@@ -86,9 +86,35 @@ class TestCacheDiTBackend:
# Verify cache-dit was enabled # Verify cache-dit was enabled
assert backend.enabled is True assert backend.enabled is True
assert backend._refresh_func is not None assert backend._refresh_funcs
mock_cache_dit.enable_cache.assert_called_once() mock_cache_dit.enable_cache.assert_called_once()
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_enable_and_refresh_every_declared_dit(self, mock_cache_dit, mock_block_adapter):
"""Combined pipelines enable and refresh Cache-DiT for every DiT."""
pipeline = Mock()
pipeline.__class__.__name__ = "MiniMaxH3Pipeline"
pipeline._dit_modules = ["transformer", "transformers_ref"]
pipeline.transformer = Mock()
pipeline.transformers_ref = Mock()
for transformer in (pipeline.transformer, pipeline.transformers_ref):
transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={"blocks": ForwardPattern.Pattern_3}
)
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
backend.enable(pipeline)
backend.refresh(pipeline, num_inference_steps=20)
assert len(backend._refresh_funcs) == 2
assert mock_cache_dit.enable_cache.call_count == 2
assert mock_cache_dit.refresh_context.call_count == 2
assert {call.args[0] for call in mock_cache_dit.refresh_context.call_args_list} == {
pipeline.transformer,
pipeline.transformers_ref,
}
@patch("vllm_omni.diffusion.cache.cachedit.backend.logger") @patch("vllm_omni.diffusion.cache.cachedit.backend.logger")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
def test_enable_without_model_adapter_uses_cache_dit_registry(self, mock_cache_dit, mock_logger): def test_enable_without_model_adapter_uses_cache_dit_registry(self, mock_cache_dit, mock_logger):
@@ -106,7 +132,7 @@ class TestCacheDiTBackend:
backend.enable(pipeline) backend.enable(pipeline)
assert backend.enabled is True assert backend.enabled is True
assert backend._refresh_func is not None assert backend._refresh_funcs
assert mock_cache_dit.enable_cache.call_args.args[0] is pipeline.transformer assert mock_cache_dit.enable_cache.call_args.args[0] is pipeline.transformer
mock_logger.info.assert_any_call( mock_logger.info.assert_any_call(
"Transformer %s does not declare _cache_dit_adapter_config; " "Transformer %s does not declare _cache_dit_adapter_config; "
@@ -138,7 +164,7 @@ class TestCacheDiTBackend:
assert isinstance(exc_info.value.__cause__, ValueError) assert isinstance(exc_info.value.__cause__, ValueError)
assert backend.enabled is False assert backend.enabled is False
assert backend._refresh_func is None assert backend._refresh_funcs == []
@patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter") @patch("vllm_omni.diffusion.cache.cachedit.backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit") @patch("vllm_omni.diffusion.cache.cachedit.backend.cache_dit")
@@ -245,7 +271,7 @@ class TestCacheDiTBackend:
backend.enable(mock_pipeline) backend.enable(mock_pipeline)
assert backend.enabled is True assert backend.enabled is True
assert backend._refresh_func is not None assert backend._refresh_funcs
mock_block_adapter.assert_called_once() mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.model assert adapter_kwargs["transformer"] is mock_pipeline.model
@@ -285,7 +311,7 @@ class TestCacheDiTBackend:
backend.enable(mock_pipeline) backend.enable(mock_pipeline)
assert backend.enabled is True assert backend.enabled is True
assert backend._refresh_func is not None assert backend._refresh_funcs
mock_block_adapter.assert_called_once() mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.transformer assert adapter_kwargs["transformer"] is mock_pipeline.transformer
@@ -1,6 +1,7 @@
# 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
import json
from multiprocessing.reduction import ForkingPickler from multiprocessing.reduction import ForkingPickler
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
@@ -24,12 +25,336 @@ def test_pipeline_import_registry_and_component_discovery():
"pipeline_minimax_h3", "pipeline_minimax_h3",
"MiniMaxH3Pipeline", "MiniMaxH3Pipeline",
) )
assert _DIFFUSION_MODELS["MiniMaxH3ModularPipeline"] == _DIFFUSION_MODELS["MiniMaxH3Pipeline"]
assert _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] == "get_minimax_h3_post_process_func" assert _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"] == "get_minimax_h3_post_process_func"
assert MiniMaxH3Pipeline._dit_modules == ["transformer"] assert (
_DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3ModularPipeline"] == _DIFFUSION_POST_PROCESS_FUNCS["MiniMaxH3Pipeline"]
)
assert MiniMaxH3Pipeline._dit_modules == ["transformer", "transformers_ref"]
assert MiniMaxH3Pipeline._encoder_modules == ["text_encoder"] assert MiniMaxH3Pipeline._encoder_modules == ["text_encoder"]
assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"] assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"]
def _write_partition_index(path, *, partition, tasks):
path.mkdir(parents=True)
(path / "model_index.json").write_text(
json.dumps(
{
"_class_name": "MiniMaxH3Pipeline",
"_diffusers_version": "0.32.2",
"_minimax_h3": {
"partition": partition,
"tasks": tasks,
"sigma_shift_scales": {"video": 12.0, "audio": 3.0},
},
}
),
encoding="utf-8",
)
def test_modular_diffusers_index_is_resolved_generically(tmp_path):
from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name
from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model
from vllm_omni.entrypoints.utils import resolve_model_config_path
(tmp_path / "modular_model_index.json").write_text(
json.dumps(
{
"_class_name": "MiniMaxH3ModularPipeline",
"_diffusers_version": "0.36.0.dev0",
}
),
encoding="utf-8",
)
assert is_diffusion_model(str(tmp_path))
assert resolve_model_class_name(str(tmp_path)) == "MiniMaxH3ModularPipeline"
assert resolve_model_config_path(str(tmp_path)) is None
config = OmniDiffusionConfig(model=str(tmp_path))
config.enrich_config()
assert config.model_class_name == "MiniMaxH3ModularPipeline"
assert config.supports_multimodal_inputs
assert config.max_multimodal_image_inputs == 9
assert config.supports_mixed_reference_inputs
@pytest.mark.parametrize(
("task_type", "partition"),
[
(None, "combined"),
("auto", "combined"),
("t2va", "fl2va"),
("fl2va", "fl2va"),
("ref2va", "ref2va"),
],
)
def test_startup_task_selects_weight_partition(task_type, partition):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task
assert _minimax_h3_partition_for_task(task_type) == partition
def test_startup_auto_task_uses_explicit_local_partition(tmp_path):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task
for directory, partition, tasks in (
("FL2VA", "fl2va", ["t2va", "fl2va"]),
("Ref2VA", "ref2va", ["ref2va"]),
):
model_path = tmp_path / directory
_write_partition_index(model_path, partition=partition, tasks=tasks)
assert _minimax_h3_partition_for_task(None, str(model_path)) == partition
assert _minimax_h3_partition_for_task("auto", str(model_path)) == partition
assert _minimax_h3_partition_for_task("combined", str(model_path)) == "combined"
def test_startup_task_rejects_unsupported_value():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import _minimax_h3_partition_for_task
with pytest.raises(ValueError, match="task_type must be one of"):
_minimax_h3_partition_for_task("unsupported")
def test_combined_task_inference_and_transformer_routing():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
pipeline.transformer = torch.nn.Identity()
pipeline.transformers_ref = torch.nn.Linear(1, 1)
assert pipeline._resolve_task(None, {}) == "t2va"
assert pipeline._resolve_task(None, {"image": object()}) == "fl2va"
assert pipeline._resolve_task(None, {"audio": object()}) == "ref2va"
assert pipeline._resolve_task(None, {"video": object()}) == "ref2va"
pipeline.partition = "ref2va"
pipeline.supported_tasks = frozenset({"ref2va"})
assert pipeline._resolve_task(None, {"image": object()}) == "ref2va"
pipeline.partition = "combined"
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
assert pipeline._transformer_for_task("fl2va") is pipeline.transformer
assert pipeline._transformer_for_task("ref2va") is pipeline.transformers_ref
@pytest.mark.parametrize(
(
"task_type",
"expected_partition",
"expected_dits",
"component_partition",
"source_partitions",
"expected_tasks",
),
[
(None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}),
("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}),
("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}),
],
)
def test_pipeline_loads_task_selected_dits_and_shared_components_once(
monkeypatch,
tmp_path,
task_type,
expected_partition,
expected_dits,
component_partition,
source_partitions,
expected_tasks,
):
from vllm_omni.diffusion.data import (
DiffusionParallelConfig,
OmniDiffusionConfig,
)
from vllm_omni.diffusion.models.minimax_h3 import (
pipeline_minimax_h3 as pipeline_module,
)
_write_partition_index(
tmp_path / "FL2VA",
partition="fl2va",
tasks=["t2va", "fl2va"],
)
_write_partition_index(
tmp_path / "Ref2VA",
partition="ref2va",
tasks=["ref2va"],
)
for partition_name in ("FL2VA", "Ref2VA"):
for component in (
"transformer",
"tokenizer",
"processor",
"text_encoder",
"video_vae",
"audio_vae",
):
(tmp_path / partition_name / component).mkdir()
created = {"dit": [], "text_encoder": [], "video_vae": [], "audio_vae": []}
class FakeModule(torch.nn.Module):
def __init__(self, *args, **kwargs):
super().__init__()
class FakeDiT(FakeModule):
def __init__(self, *args, **kwargs):
super().__init__()
created["dit"].append((args, kwargs))
def component_factory(name):
def create(path, *args, **kwargs):
created[name].append(str(path))
return FakeModule()
return create
tokenizer_calls = []
processor_calls = []
tokenizer_cls = Mock(spec=pipeline_module.Qwen2TokenizerFast)
tokenizer_cls.from_pretrained.side_effect = lambda *args, **kwargs: (
tokenizer_calls.append((args, kwargs)) or object()
)
processor_cls = Mock(spec=pipeline_module.Qwen3VLProcessor)
processor_cls.from_pretrained.side_effect = lambda *args, **kwargs: (
processor_calls.append((args, kwargs)) or object()
)
monkeypatch.setattr(pipeline_module, "MiniMaxH3DiTModel", FakeDiT)
monkeypatch.setattr(
pipeline_module,
"MiniMaxH3Qwen3VLEncoder",
component_factory("text_encoder"),
)
monkeypatch.setattr(
pipeline_module,
"MiniMaxH3VideoVAE",
component_factory("video_vae"),
)
monkeypatch.setattr(
pipeline_module,
"MiniMaxH3AudioVAE",
component_factory("audio_vae"),
)
monkeypatch.setattr(
pipeline_module,
"Qwen2TokenizerFast",
tokenizer_cls,
)
monkeypatch.setattr(
pipeline_module,
"Qwen3VLProcessor",
processor_cls,
)
monkeypatch.setattr(
pipeline_module,
"_dit_rank_world",
lambda: (None, 0, 1),
)
monkeypatch.setattr(
pipeline_module,
"get_local_device",
lambda: torch.device("cpu"),
)
download_calls = []
def fake_download(**kwargs):
download_calls.append(kwargs)
return str(tmp_path)
monkeypatch.setattr(
pipeline_module,
"download_weights_from_hf_specific",
fake_download,
)
od_config = OmniDiffusionConfig(
model="MiniMaxAI/MiniMax-H3",
revision=None,
task_type=task_type,
quantization_config=None,
parallel_config=DiffusionParallelConfig(
cfg_parallel_size=1,
text_encoder_tp_size=1,
),
)
pipeline = pipeline_module.MiniMaxH3Pipeline(od_config=od_config)
assert pipeline.partition == expected_partition
assert pipeline.supported_tasks == expected_tasks
assert len(created["dit"]) == expected_dits
component_path = tmp_path / component_partition
assert created["text_encoder"] == [str(component_path / "text_encoder")]
assert created["video_vae"] == [str(component_path / "video_vae")]
assert created["audio_vae"] == [str(component_path / "audio_vae")]
assert len(tokenizer_calls) == 1
assert len(processor_calls) == 1
expected_dit_modules = ["transformer", "transformers_ref"] if expected_dits == 2 else ["transformer"]
assert pipeline._dit_modules == expected_dit_modules
assert hasattr(pipeline, "transformers_ref") is (expected_dits == 2)
if expected_partition == "ref2va":
assert pipeline._transformer_for_task("ref2va") is pipeline.transformer
assert [source.model_or_path for source in pipeline.weights_sources] == [
str(tmp_path / partition_name) for partition_name in source_partitions
]
expected_patterns = (
pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS
if expected_partition == "combined"
else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition]
)
assert download_calls == [
{
"model_name_or_path": "MiniMaxAI/MiniMax-H3",
"cache_dir": None,
"allow_patterns": expected_patterns,
"revision": None,
"require_all": True,
}
]
def test_combined_weight_loader_routes_each_contiguous_partition():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
class FakeTransformer(torch.nn.Module):
def __init__(self):
super().__init__()
self.loaded = []
self.post_load_calls = 0
def load_weights(self, weights):
self.loaded = [name for name, _ in weights]
return set(self.loaded)
def post_load_weights(self):
self.post_load_calls += 1
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.transformer = FakeTransformer()
pipeline.transformers_ref = FakeTransformer()
pipeline.text_encoder = torch.nn.Identity()
pipeline.video_vae = torch.nn.Identity()
pipeline.audio_vae = torch.nn.Identity()
loaded = pipeline.load_weights(
iter(
[
("transformer.a", torch.ones(1)),
("transformer.b", torch.ones(1)),
("transformers_ref.a", torch.ones(1)),
]
)
)
assert pipeline.transformer.loaded == ["a", "b"]
assert pipeline.transformers_ref.loaded == ["a"]
assert pipeline.transformer.post_load_calls == 1
assert pipeline.transformers_ref.post_load_calls == 1
assert loaded == {"transformer.a", "transformer.b", "transformers_ref.a"}
def test_dlo_offload_plan_includes_token_refiner(): def test_dlo_offload_plan_includes_token_refiner():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
@@ -451,6 +776,21 @@ def test_distributed_layerwise_resident_blocks_release_on_failure():
controller.offload_resident_layers.assert_called_once_with() controller.offload_resident_layers.assert_called_once_with()
def test_distributed_layerwise_resident_blocks_can_be_skipped():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
controller = Mock()
pipeline._dlo_residency_controller = controller
with pipeline._resident_dit_layers_on_device(enabled=False):
pass
controller.load_resident_layers.assert_not_called()
controller.offload_resident_layers.assert_not_called()
def test_encoder_layerwise_offload_keeps_tp_blocks_rank_local(monkeypatch): def test_encoder_layerwise_offload_keeps_tp_blocks_rank_local(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3.encoder import ( from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder, MiniMaxH3Qwen3VLEncoder,
@@ -147,6 +147,130 @@ def test_get_npu_quant_method(mocker: MockerFixture, monkeypatch: pytest.MonkeyP
assert isinstance(method, UnquantizedLinearMethod) assert isinstance(method, UnquantizedLinearMethod)
def test_fused_layer_can_be_ignored_by_its_prefix(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture):
"""A layer opts out of quantization under the prefix its module was built with.
MiniMax H3 stores qkv as one checkpoint tensor, so the fused name is the
only handle a user has on it.
"""
from vllm_omni.quantization.int8_config import NPUInt8OnlineLinearMethod
monkeypatch.setattr(current_omni_platform, "is_cuda", lambda: False)
monkeypatch.setattr(current_omni_platform, "is_npu", lambda: True)
config = build_quant_config("int8", ignored_layers=["blocks.0.attn.qkv_proj"])
layer = mocker.Mock(spec=LinearBase)
assert isinstance(config.get_quant_method(layer, "blocks.0.attn.qkv_proj"), UnquantizedLinearMethod)
assert isinstance(config.get_quant_method(layer, "blocks.1.attn.qkv_proj"), NPUInt8OnlineLinearMethod)
assert isinstance(config.get_quant_method(layer, "blocks.0.mlp.fc1"), NPUInt8OnlineLinearMethod)
class TestNPUQuantMatmulShapeFallback:
"""npu_quant_matmul rejects wide outputs, so those layers stay unquantized.
The limit applies to the per-rank shard, which is what the kernel sees. The
same layer can therefore be quantized at a higher TP degree.
"""
@pytest.fixture(autouse=True)
def _mock_tp(self, mocker):
# The fallback delegates to UnquantizedLinearMethod, which reads the TP
# group; stand one in so the shape gating can be tested without a
# distributed init.
mock_group = mocker.Mock()
mock_group.rank_in_group = 0
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1)
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0)
mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_group)
@pytest.fixture
def quant_config(self):
from vllm_omni.quantization.int8_config import DiffusionInt8Config
return DiffusionInt8Config(is_checkpoint_int8_serialized=False, activation_scheme="dynamic")
def create_weights(self, method, layer, output_partition_sizes):
# Narrow input dim: the fallback path allocates for real, and only the
# output dimension is under test.
method.create_weights(
layer,
input_size_per_partition=8,
output_partition_sizes=output_partition_sizes,
input_size=8,
output_size=sum(output_partition_sizes),
params_dtype=torch.bfloat16,
weight_loader=lambda param, loaded_weight, *args, **kwargs: None,
)
@pytest.mark.parametrize(
"method_name",
["NPUInt8LinearMethod", "NPUInt8OnlineLinearMethod"],
)
def test_over_wide_layer_falls_back_to_unquantized(self, quant_config, method_name):
import vllm_omni.quantization.int8_config as int8_config
method = getattr(int8_config, method_name)(quant_config)
layer = Module()
layer.quant_method = method
self.create_weights(method, layer, [int8_config.NPU_QUANT_MATMUL_MAX_OUT_FEATURES + 1])
assert isinstance(layer.quant_method, UnquantizedLinearMethod)
assert layer.weight.dtype == torch.bfloat16
def test_layer_within_the_limit_keeps_int8(self, quant_config):
from vllm_omni.quantization.int8_config import (
NPU_QUANT_MATMUL_MAX_OUT_FEATURES,
NPUInt8OnlineLinearMethod,
)
method = NPUInt8OnlineLinearMethod(quant_config)
layer = Module()
layer.quant_method = method
self.create_weights(method, layer, [NPU_QUANT_MATMUL_MAX_OUT_FEATURES])
assert layer.quant_method is method
def test_tensor_parallel_sharding_brings_a_wide_layer_back(self, quant_config):
from vllm_omni.quantization.int8_config import (
NPU_QUANT_MATMUL_MAX_OUT_FEATURES,
NPUInt8OnlineLinearMethod,
)
global_out_features = 4 * NPU_QUANT_MATMUL_MAX_OUT_FEATURES
method = NPUInt8OnlineLinearMethod(quant_config)
layer = Module()
layer.quant_method = method
self.create_weights(method, layer, [global_out_features // 4])
assert layer.quant_method is method
class TestOffloadAfterQuant:
def test_only_methods_advertising_the_capability_are_asked(self):
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.quantization.int8_config import DiffusionInt8Config, NPUInt8OnlineLinearMethod
class MetaDeviceOnlyMethod:
"""Stands in for upstream online FP8: lazy, but no offload hook."""
uses_meta_device = True
model = torch.nn.Sequential(Module(), Module())
lazy_int8 = NPUInt8OnlineLinearMethod(
DiffusionInt8Config(is_checkpoint_int8_serialized=False, activation_scheme="dynamic")
)
model[0].quant_method = lazy_int8
model[1].quant_method = MetaDeviceOnlyMethod()
marked = DiffusersPipelineLoader._request_offload_after_quant(model)
assert marked == 1
assert lazy_int8._offload_after_quant
class TestInt8LinearMethod: class TestInt8LinearMethod:
@pytest.fixture @pytest.fixture
def mock_quant_config(self, mocker): def mock_quant_config(self, mocker):
@@ -131,6 +131,11 @@ def test_qwen_image_edit_plus_sets_generic_multimodal_limit():
assert od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES assert od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
def test_task_type_roundtrip():
od = _roundtrip_diffusion_config(model="x", task_type="model-defined-task")
assert od.task_type == "model-defined-task"
def test_additional_config_roundtrip(): def test_additional_config_roundtrip():
additional_config = {"torchair_graph_config": {"enabled": True}} additional_config = {"torchair_graph_config": {"enabled": True}}
od = _roundtrip_diffusion_config(model="x", additional_config=additional_config) od = _roundtrip_diffusion_config(model="x", additional_config=additional_config)
+18
View File
@@ -128,6 +128,24 @@ def test_qwen3_tts_codec_frame_rate_patching():
assert omni_config.codec_frame_rate_hz == 12.3 assert omni_config.codec_frame_rate_hz == 12.3
def test_qwen3_tts_startup_task_type_is_validated():
vllm_config = EngineArgs().create_model_config()
config = OmniModelConfig.from_vllm_model_config(
vllm_config,
model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM",
task_type="Base",
)
assert config.task_type == "Base"
with pytest.raises(ValueError, match="Qwen3-TTS --task-type must be one of"):
OmniModelConfig.from_vllm_model_config(
vllm_config,
model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM",
task_type="fl2va",
)
def test_from_cli_args_picks_up_stage_configs_path(): def test_from_cli_args_picks_up_stage_configs_path():
"""from_cli_args should pick up stage_configs_path from namespace.""" """from_cli_args should pick up stage_configs_path from namespace."""
ns = argparse.Namespace( ns = argparse.Namespace(
+510
View File
@@ -0,0 +1,510 @@
from __future__ import annotations
import copy
import sys
import types
from dataclasses import fields, replace
from pathlib import Path
import pytest
from pydantic.fields import FieldInfo
from vllm_omni.config.omni_config import VllmOmniConfig
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES, resolve_pipeline_config
from vllm_omni.config.stage_config import (
DeployConfig,
PipelineConfig,
StageDeployConfig,
StageExecutionType,
StagePipelineConfig,
StageType,
build_stage_runtime_overrides,
load_deploy_config,
merge_pipeline_deploy,
)
from vllm_omni.diffusion.data import AttentionConfig, OmniDiffusionConfig
from vllm_omni.engine import stage_init_utils
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.stage_init_utils import (
build_engine_args_dict,
build_engine_args_dict_from_omni_stage_config,
build_legacy_engine_args_dict,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_DEPLOY_DIR = Path(__file__).parents[2] / "vllm_omni" / "deploy"
def _effective_backend_values(config_cls: type, engine_args: dict) -> dict[str, object]:
backend_fields = fields(config_cls)
backend_field_names = {backend_field.name for backend_field in backend_fields}
backend_config = config_cls(
**{name: copy.deepcopy(value) for name, value in engine_args.items() if name in backend_field_names}
)
effective_values: dict[str, object] = {}
for backend_field in backend_fields:
value = getattr(backend_config, backend_field.name)
if isinstance(value, FieldInfo):
value = value.get_default(call_default_factory=True)
effective_values[backend_field.name] = value
return effective_values
_LLM_BACKEND_FIELDS = frozenset(field.name for field in fields(OmniEngineArgs))
_DIFFUSION_BACKEND_FIELDS = frozenset(field.name for field in fields(OmniDiffusionConfig))
@pytest.fixture(autouse=True)
def _stable_engine_arg_environment(monkeypatch, tmp_path):
from vllm_omni import platforms
monkeypatch.delenv("VLLM_USE_FLASHINFER_MOE_FP16", raising=False)
xcodec_model = tmp_path / "xcodec-model"
xcodec_model.mkdir()
monkeypatch.setenv("XCODEC1_PATH", str(xcodec_model))
platform = platforms.current_omni_platform
monkeypatch.setattr(platform, "device_name", "cpu", raising=False)
monkeypatch.setattr(platform, "device_type", "cpu", raising=False)
monkeypatch.setattr(platform, "is_rocm", lambda: False)
monkeypatch.setattr(
OmniDiffusionConfig,
"_resolve_master_port",
lambda config: config.master_port or 29500,
)
def _resolve_test_worker(engine_args):
worker_type = engine_args.get("worker_type")
if worker_type is not None and engine_args.get("worker_cls") in (None, "auto"):
engine_args["worker_cls"] = f"test.{worker_type}.Worker"
monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", _resolve_test_worker)
def _engine_arg_inputs(tmp_path: Path) -> tuple[PipelineConfig, DeployConfig, str]:
parent_model = tmp_path / "parent-model"
stage_model = tmp_path / "stage-model"
diffusion_model = tmp_path / "diffusion-model"
for root in (parent_model, stage_model, diffusion_model):
root.mkdir()
(stage_model / "ar-model").mkdir()
(stage_model / "ar-tokenizer").mkdir()
pipeline = PipelineConfig(
model_type="typed-engine-args",
model_arch="PipelineModel",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="thinker",
execution_type=StageExecutionType.LLM_AR,
requires_multimodal_data=True,
hf_config_name="thinker_config",
engine_output_type="hidden_states",
model_arch="Qwen3OmniMoeForConditionalGeneration",
model_subdir="ar-model",
tokenizer_subdir="ar-tokenizer",
retains_state_across_chunks=True,
async_chunk_process_next_stage_input_func="operator.add",
),
StagePipelineConfig(
stage_id=1,
model_stage="talker",
execution_type=StageExecutionType.LLM_GENERATION,
input_sources=(0,),
engine_output_type="audio_tokens",
),
StagePipelineConfig(
stage_id=2,
model_stage="dit",
execution_type=StageExecutionType.DIFFUSION,
input_sources=(1,),
final_output=True,
final_output_type="image",
model_arch="TypedDiffusionPipeline",
),
),
)
deploy = DeployConfig(
async_chunk=True,
trust_remote_code=True,
dtype="float16",
distributed_executor_backend="mp",
stages=[
StageDeployConfig(
stage_id=0,
tensor_parallel_size=2,
max_num_seqs=8,
default_sampling_params={"extra_args": {"voice": "test"}},
engine_extras={
"model": str(stage_model),
"attention_backend": "FLASHINFER",
"hf_overrides": {"rope_scaling": {"factor": 2}},
"limit_mm_per_prompt": {"audio": 1},
"logits_processors": ["test.custom.LogitsProcessor"],
"kv_cache_memory_bytes": 1024,
"omni_kv_config": {"need_send_cache": True},
"max_cudagraph_capture_size": 0,
"worker_cls": "test.custom.Worker",
},
),
StageDeployConfig(
stage_id=1,
max_num_seqs=4,
),
StageDeployConfig(
stage_id=2,
tensor_parallel_size=2,
vae_parallel_mode="spatial_shard_height",
diffusion_attention_config={
"default": {"backend": "FLASH_ATTN"},
"per_role": {"cross": {"backend": "TORCH_SDPA"}},
},
engine_extras={
"model": str(diffusion_model),
"engine_backend": "test.diffusion.Engine",
"enable_session_state_manager": True,
},
),
],
)
return pipeline, deploy, str(parent_model)
def _legacy_and_typed_stages(
pipeline: PipelineConfig,
deploy: DeployConfig,
model: str,
cli_overrides: dict[str, object] | None = None,
):
resolved_cli_overrides = {"model": model, **(cli_overrides or {})}
legacy_stage_configs = merge_pipeline_deploy(
pipeline,
copy.deepcopy(deploy),
)
for stage in legacy_stage_configs:
stage.runtime_overrides = build_stage_runtime_overrides(
stage.stage_id,
resolved_cli_overrides,
)
legacy_stages = [stage.to_omegaconf() for stage in legacy_stage_configs]
omni_config = VllmOmniConfig.from_pipeline_config(
pipeline,
user_deploy_config=copy.deepcopy(deploy),
cli_overrides=resolved_cli_overrides,
)
return legacy_stages, omni_config
def test_typed_llm_engine_args_preserve_legacy_adapter_behavior(tmp_path):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model)
connector_spec = {"name": "SharedMemoryConnector", "extra": {"mode": "test"}}
cli_tokenizer = "/external/tokenizer"
typed_args_by_stage = {}
for stage_id in (0, 1):
legacy_args = build_legacy_engine_args_dict(
legacy_stages[stage_id],
model,
stage_connector_spec=connector_spec,
cli_tokenizer=cli_tokenizer,
)
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(stage_id),
model,
stage_connector_spec=connector_spec,
cli_tokenizer=cli_tokenizer,
)
typed_args_by_stage[stage_id] = typed_args
assert {name: typed_args[name] for name in legacy_args} == legacy_args
thinker_args = typed_args_by_stage[0]
assert thinker_args["model"] == str(tmp_path / "stage-model" / "ar-model")
assert thinker_args["tokenizer"] == str(tmp_path / "stage-model" / "ar-tokenizer")
assert thinker_args["stage_id"] == 0
assert thinker_args["stage_connector_spec"] == connector_spec
assert thinker_args["has_sampling_extra_args"] is True
assert thinker_args["omni_kv_config"] == {"need_send_cache": True}
assert thinker_args["worker_cls"] == "test.custom.Worker"
assert thinker_args["attention_backend"] == "FLASHINFER"
assert thinker_args["trust_remote_code"] is True
assert thinker_args["dtype"] == "float16"
assert thinker_args["distributed_executor_backend"] == "mp"
assert thinker_args["tensor_parallel_size"] == 2
assert thinker_args["hf_overrides"] == {"rope_scaling": {"factor": 2}}
assert thinker_args["limit_mm_per_prompt"] == {"audio": 1}
assert thinker_args["logits_processors"] == ["test.custom.LogitsProcessor"]
assert thinker_args["retains_state_across_chunks"] is True
assert thinker_args["kv_cache_memory_bytes"] == 1024
assert thinker_args["max_cudagraph_capture_size"] == 0
talker_args = typed_args_by_stage[1]
assert talker_args["model"] == model
assert talker_args["tokenizer"] == cli_tokenizer
assert talker_args["worker_cls"] == "test.generation.Worker"
assert talker_args["disable_hybrid_kv_cache_manager"] is True
assert talker_args["enable_prefix_caching"] is False
def test_typed_diffusion_engine_args_use_structured_diffusion_config(tmp_path):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model)
legacy_args = build_legacy_engine_args_dict(legacy_stages[2], model)
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(2),
model,
)
assert typed_args["stage_id"] == legacy_args["stage_id"] == 2
assert typed_args["model"] == legacy_args["model"] == str(tmp_path / "diffusion-model")
assert omni_config.stage_by_id(2).diffusion_config.model == str(tmp_path / "diffusion-model")
assert typed_args["model_stage"] == legacy_args["model_stage"] == "dit"
assert typed_args["model_arch"] == legacy_args["model_arch"] == "TypedDiffusionPipeline"
assert typed_args["model_class_name"] == legacy_args["model_class_name"] == "TypedDiffusionPipeline"
assert typed_args["engine_backend"] == legacy_args["engine_backend"] == "test.diffusion.Engine"
assert typed_args["enable_session_state_manager"] is legacy_args["enable_session_state_manager"] is True
assert typed_args["parallel_config"]["tensor_parallel_size"] == 2
assert typed_args["parallel_config"]["vae_parallel_mode"] == "spatial_shard_height"
assert isinstance(typed_args["diffusion_attention_config"], AttentionConfig)
assert typed_args["diffusion_attention_config"].default.backend == "FLASH_ATTN"
assert typed_args["diffusion_attention_config"].per_role["cross"].backend == "TORCH_SDPA"
def test_typed_engine_args_preserve_explicit_backend_default_overrides(tmp_path):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
deploy.enable_prefix_caching = False
deploy.enable_chunked_prefill = False
thinker_deploy = deploy.stages[0]
thinker_deploy.gpu_memory_utilization = 0.75
thinker_deploy.disable_hybrid_kv_cache_manager = False
thinker_deploy.async_scheduling = False
legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model)
legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model)
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(0),
model,
)
expected = {
"gpu_memory_utilization": 0.75,
"enable_prefix_caching": False,
"disable_hybrid_kv_cache_manager": False,
"max_num_seqs": 8,
"enable_chunked_prefill": False,
"async_scheduling": False,
}
assert {name: legacy_args[name] for name in expected} == expected
assert {name: typed_args[name] for name in expected} == expected
@pytest.mark.parametrize("stage_scoped", [False, True], ids=["global", "stage-scoped"])
def test_typed_engine_args_preserve_inherited_model_and_load_cli_fields(tmp_path, stage_scoped):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
expected = {
"revision": "model-rev",
"tokenizer_revision": "tokenizer-rev",
"code_revision": "code-rev",
"seed": 123,
"download_dir": str(tmp_path / "downloads"),
}
cli_overrides = {f"stage_0_{name}" if stage_scoped else name: value for name, value in expected.items()}
legacy_stages, omni_config = _legacy_and_typed_stages(
pipeline,
deploy,
model,
cli_overrides,
)
legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model)
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(0),
model,
)
assert {name: legacy_args[name] for name in expected} == expected
assert {name: typed_args[name] for name in expected} == expected
def test_typed_engine_args_preserve_deploy_subdirectory_precedence(tmp_path):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
stage_model = tmp_path / "stage-model"
(stage_model / "override-model").mkdir()
(stage_model / "override-tokenizer").mkdir()
deploy.stages[0].engine_extras.update(
{
"model_subdir": "override-model",
"tokenizer_subdir": "override-tokenizer",
}
)
legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model)
typed_stage = omni_config.stage_by_id(0)
legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model)
typed_args = build_engine_args_dict_from_omni_stage_config(typed_stage, model)
assert typed_stage.model_config.model_subdir == "override-model"
assert typed_stage.model_config.tokenizer_subdir == "override-tokenizer"
assert typed_args["model"] == legacy_args["model"] == str(stage_model / "override-model")
assert typed_args["tokenizer"] == legacy_args["tokenizer"] == str(stage_model / "override-tokenizer")
@pytest.mark.parametrize(
("cli_overrides", "expected"),
[
({}, {"need_recv_cache": True}),
(
{"stage_0_omni_kv_config": {"need_send_cache": False}},
{"need_send_cache": False},
),
],
ids=["topology-over-deploy", "cli-over-topology"],
)
def test_typed_engine_args_preserve_legacy_omni_kv_precedence(tmp_path, cli_overrides, expected):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
thinker = replace(
pipeline.stages[0],
omni_kv_config={"need_recv_cache": True},
)
pipeline = replace(pipeline, stages=(thinker, *pipeline.stages[1:]))
legacy_stages, omni_config = _legacy_and_typed_stages(
pipeline,
deploy,
model,
cli_overrides,
)
legacy_args = build_legacy_engine_args_dict(legacy_stages[0], model)
typed_stage = omni_config.stage_by_id(0)
typed_args = build_engine_args_dict_from_omni_stage_config(typed_stage, model)
assert typed_stage.connector_config.omni_kv_config == expected
assert typed_args["omni_kv_config"] == legacy_args["omni_kv_config"] == expected
def test_build_engine_args_dict_preserves_legacy_api(tmp_path):
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
legacy_stages, _ = _legacy_and_typed_stages(pipeline, deploy, model)
connector_spec = {"name": "SharedMemoryConnector", "extra": {}}
compatibility_args = build_engine_args_dict(
copy.deepcopy(legacy_stages[0]),
model,
stage_connector_spec=connector_spec,
cli_tokenizer="/external/tokenizer",
)
explicitly_legacy_args = build_legacy_engine_args_dict(
copy.deepcopy(legacy_stages[0]),
model,
stage_connector_spec=connector_spec,
cli_tokenizer="/external/tokenizer",
)
assert compatibility_args == explicitly_legacy_args
def test_legacy_engine_args_drop_none_tp_without_mutating_stage_config():
stage_config = types.SimpleNamespace(
stage_id=0,
stage_type="llm",
engine_args={"tensor_parallel_size": None},
default_sampling_params={},
)
engine_args = build_legacy_engine_args_dict(
stage_config,
model="test-model",
)
assert "tensor_parallel_size" not in engine_args
assert stage_config.engine_args == {"tensor_parallel_size": None}
def test_typed_engine_args_own_rocm_attention_default(monkeypatch, tmp_path):
from vllm_omni import platforms
pipeline, deploy, model = _engine_arg_inputs(tmp_path)
legacy_stages, omni_config = _legacy_and_typed_stages(pipeline, deploy, model)
monkeypatch.setattr(platforms.current_omni_platform, "is_rocm", lambda: True)
aiter_module = types.ModuleType("vllm._aiter_ops")
setattr(
aiter_module,
"rocm_aiter_ops",
types.SimpleNamespace(is_enabled=lambda: False),
)
monkeypatch.setitem(sys.modules, "vllm._aiter_ops", aiter_module)
legacy_args = build_legacy_engine_args_dict(legacy_stages[1], model)
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(1),
model,
)
assert "attention_backend" not in legacy_args
assert typed_args["attention_backend"] == "TRITON_ATTN"
def test_typed_ming_image_engine_args_defer_diffusion_batch_default():
pipeline = resolve_pipeline_config("ming_flash_omni_image")
assert pipeline is not None
deploy = load_deploy_config(_DEPLOY_DIR / "ming_flash_omni_image.yaml")
legacy_stages, omni_config = _legacy_and_typed_stages(
pipeline,
deploy,
model="/tmp",
)
legacy_args = build_legacy_engine_args_dict(legacy_stages[1], model="/tmp")
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(1),
model="/tmp",
)
typed_backend_args = {name: value for name, value in typed_args.items() if name in _DIFFUSION_BACKEND_FIELDS}
assert "max_num_seqs" not in legacy_args
assert "max_num_seqs" not in typed_args
assert OmniDiffusionConfig(**typed_backend_args).max_num_seqs == 1
@pytest.mark.parametrize("model_type", sorted(OMNI_PIPELINES))
def test_typed_engine_args_match_current_registry_backend_semantics(model_type):
pipeline = resolve_pipeline_config(model_type)
if pipeline is None:
pytest.skip(f"Pipeline {model_type!r} requires an HF config to resolve")
deploy = (
load_deploy_config(_DEPLOY_DIR / pipeline.default_deploy_config_name)
if pipeline.default_deploy_config_name is not None
else DeployConfig()
)
legacy_stages, omni_config = _legacy_and_typed_stages(
pipeline,
deploy,
model="/tmp",
)
for legacy_stage in legacy_stages:
stage_id = legacy_stage.stage_id
legacy_args = build_legacy_engine_args_dict(legacy_stage, model="/tmp")
typed_args = build_engine_args_dict_from_omni_stage_config(
omni_config.stage_by_id(stage_id),
model="/tmp",
)
if legacy_stage.stage_type == StageType.DIFFUSION:
backend_fields = _DIFFUSION_BACKEND_FIELDS
backend_config_cls = OmniDiffusionConfig
else:
backend_fields = _LLM_BACKEND_FIELDS
backend_config_cls = OmniEngineArgs
missing_fields = (legacy_args.keys() & backend_fields) - typed_args.keys()
assert not missing_fields, f"{model_type} stage {stage_id} lost backend fields: {sorted(missing_fields)}"
legacy_effective_args = _effective_backend_values(backend_config_cls, legacy_args)
typed_effective_args = _effective_backend_values(backend_config_cls, typed_args)
assert typed_effective_args == legacy_effective_args, (
f"{model_type} stage {stage_id} changed effective backend arguments"
)
@@ -48,6 +48,52 @@ def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
assert mux_calls[0]["audio"] is None assert mux_calls[0]["audio"] is None
def test_float_frames_are_converted_without_stacking_full_video(monkeypatch):
frame = np.array(
[
[[0.0, 0.5, 1.0, 0.2], [1.5, -0.5, 0.5, 0.8]],
],
dtype=np.float32,
)
original = frame.copy()
def fail_stack(*args, **kwargs):
raise AssertionError("float video conversion must not stack all frames")
monkeypatch.setattr(video_api_utils.np, "stack", fail_stack)
frames = video_api_utils._coerce_video_to_uint8_frames([frame, frame])
expected = np.array([[[128, 191, 255], [255, 64, 191]]], dtype=np.uint8)
assert frames.flags.c_contiguous
np.testing.assert_array_equal(frames, np.array([expected, expected]))
np.testing.assert_array_equal(frame, original)
def test_two_dimensional_width_four_frames_are_not_treated_as_rgba():
frame = np.arange(8, dtype=np.float32).reshape(2, 4) / 8
frames = video_api_utils._coerce_video_to_uint8_frames([frame, frame])
expected = np.rint(frame * 255).astype(np.uint8)
assert frames.shape == (2, 2, 4)
np.testing.assert_array_equal(frames, np.stack([expected, expected]))
def test_mixed_float_dtypes_preserve_stacked_rounding_semantics():
frames = [
np.full((1, 1, 3), 0.1, dtype=np.float16),
np.full((1, 1, 3), 0.2, dtype=np.float32),
]
stacked = np.stack(frames)
expected = np.rint(np.clip(stacked, 0.0, 1.0) * 255.0).astype(np.uint8)
actual = video_api_utils._coerce_video_to_uint8_frames(frames)
assert expected[0, 0, 0, 0] == 25
np.testing.assert_array_equal(actual, expected)
@pytest.mark.parametrize("frame_count", [3, 4]) @pytest.mark.parametrize("frame_count", [3, 4])
def test_channel_last_video_tensor_preserves_channel_sized_frame_count(frame_count): def test_channel_last_video_tensor_preserves_channel_sized_frame_count(frame_count):
video = torch.arange(frame_count * 2 * 5 * 3, dtype=torch.uint8).reshape(frame_count, 2, 5, 3) video = torch.arange(frame_count * 2 * 5 * 3, dtype=torch.uint8).reshape(frame_count, 2, 5, 3)
@@ -240,6 +240,28 @@ def test_serve_cli_accepts_ulysses_mode():
assert parallel_config.ulysses_mode == "advanced_uaa" assert parallel_config.ulysses_mode == "advanced_uaa"
def test_serve_cli_forwards_model_defined_task_type_to_diffusion_stage():
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="command")
OmniServeCommand().subparser_init(subparsers)
args = parser.parse_args(
[
"serve",
"MiniMaxAI/MiniMax-H3",
"--omni",
"--task-type",
"fl2va",
]
)
explicit_kwargs = args.get_explicit_kwargs_dict()
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0]
assert args.task_type == "fl2va"
assert stage_cfg["engine_args"]["task_type"] == "fl2va"
def test_serve_cli_accepts_diffusion_pipeline_profiler_flag(): def test_serve_cli_accepts_diffusion_pipeline_profiler_flag():
"""Ensure diffusion serve CLI exposes the profiler switch.""" """Ensure diffusion serve CLI exposes the profiler switch."""
parser = TrackingArgumentParser() parser = TrackingArgumentParser()
@@ -53,6 +53,11 @@ def download_backend(monkeypatch: pytest.MonkeyPatch):
"download_weights_from_hf_specific", "download_weights_from_hf_specific",
lambda **_kwargs: picked.append("huggingface"), lambda **_kwargs: picked.append("huggingface"),
) )
monkeypatch.setattr(
omni_base,
"file_or_path_exists",
lambda *_args, **_kwargs: False,
)
def run() -> str: def run() -> str:
picked.clear() picked.clear()
@@ -82,3 +87,14 @@ def test_unset_defaults_to_huggingface(monkeypatch, download_backend):
monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False) monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False)
assert download_backend() == "huggingface" assert download_backend() == "huggingface"
def test_modular_diffusers_defers_component_download(monkeypatch, download_backend):
monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False)
monkeypatch.setattr(
omni_base,
"file_or_path_exists",
lambda *_args, **_kwargs: True,
)
assert download_backend() == "none"
+60 -1
View File
@@ -2,10 +2,69 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …).""" """Pytest marks and decorators for hardware / resource selection (CUDA, ROCm, …)."""
from __future__ import annotations
import re
from functools import lru_cache
from pathlib import Path
import pytest import pytest
from vllm.platforms import current_platform from vllm.platforms import current_platform
# Re-exported from tests.helpers.env (GPU wait + DeviceMemoryMonitor). # Marker description tag in ``pyproject.toml`` ``tool.pytest.ini_options.markers``.
# Example: ``"H100: [hardware-resource] Tests that require H100 GPU"``.
_HARDWARE_RESOURCE_MARKER_TAG = "[hardware-resource]"
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_pyproject() -> dict:
path = _repo_root() / "pyproject.toml"
try:
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
with path.open("rb") as f:
return tomllib.load(f)
except Exception:
return {}
@lru_cache(maxsize=1)
def get_hardware_mark_list() -> frozenset[str]:
"""Return hardware SKU marker names from ``pyproject.toml``.
A marker is treated as a hardware resource marker when its description
contains the ``[hardware-resource]`` tag, e.g.::
"H100: [hardware-resource] Tests that require H100 GPU"
"""
data = _load_pyproject()
entries = data.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("markers", [])
if not entries:
# Fallback when TOML parsing fails: scan tagged marker lines.
text = (_repo_root() / "pyproject.toml").read_text(encoding="utf-8")
return frozenset(
re.findall(
rf'^\s*"([A-Za-z0-9_]+)\s*:\s*{re.escape(_HARDWARE_RESOURCE_MARKER_TAG)}',
text,
flags=re.M,
)
)
names: set[str] = set()
for entry in entries:
text = str(entry)
if _HARDWARE_RESOURCE_MARKER_TAG not in text:
continue
name = text.split(":", 1)[0].strip()
if name:
names.add(name)
return frozenset(names)
def cuda_marks(*, res: str, num_cards: int): def cuda_marks(*, res: str, num_cards: int):
@@ -0,0 +1,353 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression coverage for MiniCPM-o 4.5 SigLIP FlashAttention."""
from __future__ import annotations
from collections.abc import Callable
import pytest
import torch
from pytest_mock import MockerFixture
from transformers.utils import is_flash_attn_2_available
from tests.helpers.mark import hardware_test
from vllm_omni.model_executor.models.minicpmo_4_5 import minicpmo_4_5_omni_llm as minicpmo_model
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
SiglipAttention,
SiglipEncoder,
SiglipFlashAttention2,
SiglipVisionConfig,
)
_VISION_NUM_HEADS = 16
_VISION_HEAD_DIM = 72
_VISION_HIDDEN_SIZE = _VISION_NUM_HEADS * _VISION_HEAD_DIM
_DTYPE = torch.bfloat16
def _attention_config(*, hidden_size: int, num_heads: int) -> SiglipVisionConfig:
return SiglipVisionConfig(
hidden_size=hidden_size,
num_attention_heads=num_heads,
attention_dropout=0.0,
)
def _encoder_config(*, num_layers: int = 3) -> SiglipVisionConfig:
config = SiglipVisionConfig(
hidden_size=32,
intermediate_size=64,
num_hidden_layers=num_layers,
num_attention_heads=4,
attention_dropout=0.0,
)
config._attn_implementation = "flash_attention_2"
return config
def _capture_output(
outputs: dict[str, torch.Tensor], name: str
) -> Callable[[torch.nn.Module, tuple[torch.Tensor, ...], torch.Tensor], None]:
def hook(
_module: torch.nn.Module,
_inputs: tuple[torch.Tensor, ...],
output: torch.Tensor,
) -> None:
outputs[name] = output
return hook
@pytest.mark.core_model
@pytest.mark.cpu
def test_qkv_bshd_layout_reuses_projection_storage(mocker: MockerFixture) -> None:
batch_size = 2
seq_len = 7
hidden_size = 32
num_heads = 4
head_dim = hidden_size // num_heads
attention = SiglipFlashAttention2(_attention_config(hidden_size=hidden_size, num_heads=num_heads)).eval()
projected: dict[str, torch.Tensor] = {}
attention.q_proj.register_forward_hook(_capture_output(projected, "query"))
attention.k_proj.register_forward_hook(_capture_output(projected, "key"))
attention.v_proj.register_forward_hook(_capture_output(projected, "value"))
flash_inputs: dict[str, torch.Tensor] = {}
def fake_flash_attention(
query_states: torch.Tensor,
key_states: torch.Tensor,
value_states: torch.Tensor,
_attention_mask: torch.Tensor | None,
_query_length: int,
*,
dropout: float = 0.0,
softmax_scale: float | None = None,
unpadding_metadata: minicpmo_model.FlashAttentionUnpaddingMetadata | None = None,
) -> torch.Tensor:
del dropout, softmax_scale, unpadding_metadata
assert _attention_mask is None
assert _query_length == seq_len
flash_inputs.update(
query=query_states,
key=key_states,
value=value_states,
)
return query_states
flash_forward = mocker.patch.object(
attention,
"_flash_attention_forward",
side_effect=fake_flash_attention,
)
hidden_states = torch.randn(batch_size, seq_len, hidden_size)
with torch.inference_mode():
output, _ = attention(hidden_states)
assert output.shape == hidden_states.shape
flash_forward.assert_called_once()
expected_shape = (batch_size, seq_len, num_heads, head_dim)
expected_stride = (
seq_len * num_heads * head_dim,
num_heads * head_dim,
head_dim,
1,
)
for name in ("query", "key", "value"):
projected_tensor = projected[name]
flash_tensor = flash_inputs[name]
assert flash_tensor.shape == expected_shape
assert flash_tensor.stride() == expected_stride
assert flash_tensor.is_contiguous()
assert flash_tensor.storage_offset() == projected_tensor.storage_offset()
assert flash_tensor.untyped_storage().data_ptr() == projected_tensor.untyped_storage().data_ptr()
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize(
("attention_mask", "expected_calls"),
[
pytest.param(torch.tensor([[True, True, False], [True, False, False]]), 1, id="padded"),
pytest.param(None, 0, id="dense"),
],
)
def test_encoder_computes_unpadding_metadata_once_per_forward(
mocker: MockerFixture,
attention_mask: torch.Tensor | None,
expected_calls: int,
) -> None:
encoder = SiglipEncoder(_encoder_config()).eval()
metadata_spy = mocker.spy(minicpmo_model, "_get_unpad_data")
received_metadata: list[minicpmo_model.FlashAttentionUnpaddingMetadata | None] = []
def fake_flash_attention(
query_states: torch.Tensor,
key_states: torch.Tensor,
value_states: torch.Tensor,
_attention_mask: torch.Tensor | None,
_query_length: int,
*,
dropout: float = 0.0,
softmax_scale: float | None = None,
unpadding_metadata: minicpmo_model.FlashAttentionUnpaddingMetadata | None = None,
) -> torch.Tensor:
del key_states, value_states, _attention_mask, _query_length, dropout, softmax_scale
received_metadata.append(unpadding_metadata)
return query_states
for layer in encoder.layers:
mocker.patch.object(layer.self_attn, "_flash_attention_forward", side_effect=fake_flash_attention)
hidden_states = torch.randn(2, 3, 32)
with torch.inference_mode():
encoder(
inputs_embeds=hidden_states,
attention_mask=attention_mask,
output_attentions=False,
output_hidden_states=False,
return_dict=False,
)
assert metadata_spy.call_count == expected_calls
assert len(received_metadata) == len(encoder.layers)
if attention_mask is None:
assert all(metadata is None for metadata in received_metadata)
else:
assert received_metadata[0] is not None
assert all(metadata is received_metadata[0] for metadata in received_metadata)
@pytest.mark.core_model
@pytest.mark.cpu
def test_encoder_recomputes_unpadding_metadata_for_new_mask(mocker: MockerFixture) -> None:
encoder = SiglipEncoder(_encoder_config(num_layers=1)).eval()
metadata_spy = mocker.spy(minicpmo_model, "_get_unpad_data")
received_metadata: list[minicpmo_model.FlashAttentionUnpaddingMetadata | None] = []
def fake_flash_attention(
query_states: torch.Tensor,
key_states: torch.Tensor,
value_states: torch.Tensor,
_attention_mask: torch.Tensor | None,
_query_length: int,
*,
dropout: float = 0.0,
softmax_scale: float | None = None,
unpadding_metadata: minicpmo_model.FlashAttentionUnpaddingMetadata | None = None,
) -> torch.Tensor:
del key_states, value_states, _attention_mask, _query_length, dropout, softmax_scale
received_metadata.append(unpadding_metadata)
return query_states
mocker.patch.object(
encoder.layers[0].self_attn,
"_flash_attention_forward",
side_effect=fake_flash_attention,
)
hidden_states = torch.randn(2, 4, 32)
masks = (
torch.tensor([[True, True, True, False], [True, True, False, False]]),
torch.tensor([[True, True, False, False], [True, False, False, False]]),
)
with torch.inference_mode():
for attention_mask in masks:
encoder(
inputs_embeds=hidden_states,
attention_mask=attention_mask,
output_attentions=False,
output_hidden_states=False,
return_dict=False,
)
assert metadata_spy.call_count == len(masks)
first_metadata, second_metadata = received_metadata
assert first_metadata is not None
assert second_metadata is not None
assert first_metadata is not second_metadata
assert not torch.equal(first_metadata[0], second_metadata[0])
def _require_flash_attention() -> None:
if not torch.cuda.is_available():
pytest.skip("CUDA is required for FlashAttention execution")
if not is_flash_attn_2_available():
pytest.skip("Transformers reports that FlashAttention 2 is unavailable")
def _make_cuda_attention_pair() -> tuple[SiglipAttention, SiglipFlashAttention2]:
config = _attention_config(
hidden_size=_VISION_HIDDEN_SIZE,
num_heads=_VISION_NUM_HEADS,
)
torch.manual_seed(0)
eager_attention = SiglipAttention(config).eval().to(device="cuda", dtype=_DTYPE)
flash_attention = SiglipFlashAttention2(config).eval().to(device="cuda", dtype=_DTYPE)
flash_attention.load_state_dict(eager_attention.state_dict())
return eager_attention, flash_attention
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"})
def test_encoder_metadata_reuse_matches_per_layer_recomputation() -> None:
_require_flash_attention()
encoder = SiglipEncoder(_encoder_config()).eval().to(device="cuda", dtype=_DTYPE)
torch.manual_seed(3)
hidden_states = torch.randn(2, 64, 32, device="cuda", dtype=_DTYPE)
attention_mask = torch.arange(64, device="cuda").unsqueeze(0) < torch.tensor(
(64, 43),
device="cuda",
).unsqueeze(1)
with torch.inference_mode():
expected = hidden_states
for encoder_layer in encoder.layers:
expected = encoder_layer(
expected,
attention_mask,
output_attentions=False,
unpadding_metadata=None,
)[0]
actual = encoder(
inputs_embeds=hidden_states,
attention_mask=attention_mask,
output_attentions=False,
output_hidden_states=False,
return_dict=False,
)[0]
torch.testing.assert_close(actual, expected, atol=0.0, rtol=0.0)
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"})
def test_flash_attention_unpadded_matches_eager_reference() -> None:
_require_flash_attention()
eager_attention, flash_attention = _make_cuda_attention_pair()
torch.manual_seed(1)
hidden_states = torch.randn(
2,
64,
_VISION_HIDDEN_SIZE,
device="cuda",
dtype=_DTYPE,
)
with torch.inference_mode():
expected, _ = eager_attention(hidden_states)
actual, _ = flash_attention(hidden_states)
torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2)
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4"})
@pytest.mark.parametrize(
"valid_lengths",
[
pytest.param((64, 43), id="moderate_padding"),
pytest.param((64, 17), id="high_padding"),
],
)
def test_flash_attention_padded_matches_per_sample_unpadded(
valid_lengths: tuple[int, int],
) -> None:
_require_flash_attention()
_, flash_attention = _make_cuda_attention_pair()
batch_size = len(valid_lengths)
seq_len = max(valid_lengths)
torch.manual_seed(2)
hidden_states = torch.randn(
batch_size,
seq_len,
_VISION_HIDDEN_SIZE,
device="cuda",
dtype=_DTYPE,
)
attention_mask = torch.arange(seq_len, device="cuda").unsqueeze(0) < torch.tensor(
valid_lengths,
device="cuda",
).unsqueeze(1)
with torch.inference_mode():
padded_output, _ = flash_attention(
hidden_states,
attention_mask=attention_mask,
)
unpadded_outputs = [
flash_attention(hidden_states[index : index + 1, :valid_length])[0]
for index, valid_length in enumerate(valid_lengths)
]
for index, valid_length in enumerate(valid_lengths):
torch.testing.assert_close(
padded_output[index : index + 1, :valid_length],
unpadded_outputs[index],
atol=2e-2,
rtol=2e-2,
)
@@ -33,6 +33,7 @@ EXCLUDED_MODELS = [
"LTX2T2VDMD2Pipeline", "LTX2T2VDMD2Pipeline",
"LTX2I2VDMD2Pipeline", "LTX2I2VDMD2Pipeline",
"MiniMaxH3Pipeline", "MiniMaxH3Pipeline",
"MiniMaxH3ModularPipeline",
"StableAudioPipeline", "StableAudioPipeline",
"WanImageToVideoPipeline", "WanImageToVideoPipeline",
"WanS2VPipeline", "WanS2VPipeline",
+13 -2
View File
@@ -17,6 +17,8 @@ import vllm_omni.model_executor.models as me_models
logger = init_logger(__name__) logger = init_logger(__name__)
_QWEN3_TTS_TASK_TYPES = frozenset({"CustomVoice", "VoiceDesign", "Base"})
class OmniModelArchConfigConvertor(ModelArchConfigConvertorBase): class OmniModelArchConfigConvertor(ModelArchConfigConvertorBase):
"""Config convertor for Omni multi-stage models. """Config convertor for Omni multi-stage models.
@@ -101,8 +103,8 @@ class OmniModelConfig(ModelConfig):
"audio", "latents"). If None, output type is inferred. "audio", "latents"). If None, output type is inferred.
stage_connector_config: Stage connector configuration dictionary. stage_connector_config: Stage connector configuration dictionary.
Contains "name" (connector name), "extra" (extra connector config). Contains "name" (connector name), "extra" (extra connector config).
task_type: Default task type for TTS models (CustomVoice, VoiceDesign, or Base). task_type: Model-defined startup task type. Each model validates its
If not specified, will be inferred from model path. supported values and applies the corresponding behavior.
The correct way to initialize this class is via vLLM config, as most The correct way to initialize this class is via vLLM config, as most
@@ -208,6 +210,14 @@ class OmniModelConfig(ModelConfig):
) )
return get_hf_text_config(self.hf_config) return get_hf_text_config(self.hf_config)
def _validate_startup_task_type(self) -> None:
"""Validate startup-only task selectors owned by an AR model."""
if self.model_arch != "Qwen3TTSTalkerForConditionalGenerationARVLLM" or self.task_type is None:
return
if self.task_type not in _QWEN3_TTS_TASK_TYPES:
supported = ", ".join(sorted(_QWEN3_TTS_TASK_TYPES))
raise ValueError(f"Qwen3-TTS --task-type must be one of {supported}; got {self.task_type!r}")
def _patch_qwen3_tts(self): def _patch_qwen3_tts(self):
"""Patches the value of `position_id_per_seconds` in Qwen3's """Patches the value of `position_id_per_seconds` in Qwen3's
TTS's talker_config into the this class's codec_frame_rate_hz. TTS's talker_config into the this class's codec_frame_rate_hz.
@@ -275,6 +285,7 @@ class OmniModelConfig(ModelConfig):
omni_cfg._patch_qwen3_tts() omni_cfg._patch_qwen3_tts()
omni_cfg._maybe_override_text_config() omni_cfg._maybe_override_text_config()
omni_cfg._validate_startup_task_type()
if omni_cfg.hf_config is not None: if omni_cfg.hf_config is not None:
omni_cfg.hf_config.architectures = omni_cfg.architectures omni_cfg.hf_config.architectures = omni_cfg.architectures
+268 -27
View File
@@ -46,6 +46,29 @@ _EXECUTION_TYPE_TO_STAGE_WORKER: dict[StageExecutionType, tuple[StageType, str |
_PIPELINE_DEPLOY_CLI_FIELDS = PIPELINE_WIDE_ENGINE_FIELDS _PIPELINE_DEPLOY_CLI_FIELDS = PIPELINE_WIDE_ENGINE_FIELDS
_NON_STAGE_ENGINE_CLI_FIELDS = frozenset(
{
"async_chunk",
"model",
"omni",
"output_modalities",
"stage_configs_path",
"stage_id",
"tokenizer",
}
)
# Legacy deploy files can still carry StageConfig metadata in engine_extras.
# Those values are not backend engine inputs, and the additive typed path keeps
# sourcing their effective values from the immutable pipeline topology.
_LEGACY_STAGE_METADATA_EXTRA_FIELDS = frozenset(
{
"final_output",
"final_output_type",
"is_comprehension",
}
)
_QuantizationConfigType: TypeAlias = QuantizationConfig | str | Mapping[str, Any] | None _QuantizationConfigType: TypeAlias = QuantizationConfig | str | Mapping[str, Any] | None
@@ -55,6 +78,21 @@ class _QuantizationEngineOverrides(TypedDict, total=False):
class _ModelEngineOverrides(TypedDict, total=False): class _ModelEngineOverrides(TypedDict, total=False):
model: str
model_arch: str
model_subdir: str
tokenizer_subdir: str
revision: str
tokenizer_revision: str
code_revision: str
seed: int
logits_processors: list[str | type]
trust_remote_code: bool
dtype: Any
attention_backend: Any
moe_backend: str
hf_overrides: Any
limit_mm_per_prompt: dict[str, Any]
active_stream_window: int active_stream_window: int
enable_sleep_mode: bool enable_sleep_mode: bool
subtalker_sampling_params: dict[str, Any] subtalker_sampling_params: dict[str, Any]
@@ -63,6 +101,7 @@ class _ModelEngineOverrides(TypedDict, total=False):
task_type: str task_type: str
codec_frame_rate_hz: float codec_frame_rate_hz: float
enforce_eager: bool enforce_eager: bool
max_cudagraph_capture_size: int
enable_flashinfer_autotune: bool enable_flashinfer_autotune: bool
compilation_config: dict[str, Any] compilation_config: dict[str, Any]
enable_multithread_weight_load: bool enable_multithread_weight_load: bool
@@ -71,6 +110,9 @@ class _ModelEngineOverrides(TypedDict, total=False):
class _LoadEngineOverrides(TypedDict, total=False): class _LoadEngineOverrides(TypedDict, total=False):
tokenizer: str
download_dir: str
skip_tokenizer_init: bool
load_format: str load_format: str
tokenizer_mode: str tokenizer_mode: str
config_format: str config_format: str
@@ -78,6 +120,7 @@ class _LoadEngineOverrides(TypedDict, total=False):
class _CacheEngineOverrides(TypedDict, total=False): class _CacheEngineOverrides(TypedDict, total=False):
kv_cache_memory_bytes: int
gpu_memory_utilization: float gpu_memory_utilization: float
enable_prefix_caching: bool enable_prefix_caching: bool
disable_hybrid_kv_cache_manager: bool disable_hybrid_kv_cache_manager: bool
@@ -93,6 +136,8 @@ class _SchedulerEngineOverrides(TypedDict, total=False):
class _RuntimeEngineOverrides(TypedDict, total=False): class _RuntimeEngineOverrides(TypedDict, total=False):
distributed_executor_backend: Any
worker_cls: str
devices: str devices: str
num_replicas: int num_replicas: int
env: dict[str, Any] env: dict[str, Any]
@@ -113,6 +158,7 @@ class _ParallelConfigEngineOverrides(TypedDict, total=False):
ulysses_mode: str ulysses_mode: str
cfg_parallel_size: int cfg_parallel_size: int
vae_patch_parallel_size: int vae_patch_parallel_size: int
vae_parallel_mode: str
text_encoder_tp_size: int text_encoder_tp_size: int
use_hsdp: bool use_hsdp: bool
mask_sp_padding: bool mask_sp_padding: bool
@@ -125,6 +171,10 @@ class _ParallelEngineOverrides(_ParallelConfigEngineOverrides, total=False):
parallel_config: _ParallelConfigEngineOverrides | Mapping[str, Any] parallel_config: _ParallelConfigEngineOverrides | Mapping[str, Any]
class _ConnectorEngineOverrides(TypedDict, total=False):
omni_kv_config: dict[str, Any]
@dataclass(frozen=True) @dataclass(frozen=True)
class _StageEngineValues: class _StageEngineValues:
"""Typed projections of legacy flat per-stage ``yaml_engine_args``.""" """Typed projections of legacy flat per-stage ``yaml_engine_args``."""
@@ -134,6 +184,7 @@ class _StageEngineValues:
load: _LoadEngineOverrides load: _LoadEngineOverrides
cache: _CacheEngineOverrides cache: _CacheEngineOverrides
scheduler: _SchedulerEngineOverrides scheduler: _SchedulerEngineOverrides
connector: _ConnectorEngineOverrides
runtime: _RuntimeEngineOverrides runtime: _RuntimeEngineOverrides
parallel: _ParallelEngineOverrides parallel: _ParallelEngineOverrides
diffusion: _DiffusionEngineOverrides diffusion: _DiffusionEngineOverrides
@@ -255,8 +306,21 @@ def _get_deploy_config(
@config @config
class OmniStageModelConfig: class OmniStageModelConfig:
"""Per-stage model behavior.""" """Per-stage model behavior and resolved model-engine inputs."""
model: str | None = None
model_arch: str | None = None
revision: str | None = None
tokenizer_revision: str | None = None
code_revision: str | None = None
seed: int | None = None
logits_processors: list[str | type] | None = None
trust_remote_code: bool = False
dtype: Any = "auto"
attention_backend: Any = None
moe_backend: str = "auto"
hf_overrides: Any = None
limit_mm_per_prompt: dict[str, Any] | None = None
active_stream_window: int = Field(default=0, ge=0) active_stream_window: int = Field(default=0, ge=0)
duplex_max_sessions: int = Field(default=1, ge=1) duplex_max_sessions: int = Field(default=1, ge=1)
enable_sleep_mode: bool = False enable_sleep_mode: bool = False
@@ -267,6 +331,7 @@ class OmniStageModelConfig:
task_type: str | None = None task_type: str | None = None
codec_frame_rate_hz: float | None = None codec_frame_rate_hz: float | None = None
enforce_eager: bool = False enforce_eager: bool = False
max_cudagraph_capture_size: int | None = Field(default=None, ge=0)
enable_flashinfer_autotune: bool | None = None enable_flashinfer_autotune: bool | None = None
compilation_config: dict[str, Any] | None = None compilation_config: dict[str, Any] | None = None
enable_multithread_weight_load: bool = True enable_multithread_weight_load: bool = True
@@ -281,8 +346,11 @@ class OmniStageModelConfig:
@config @config
class OmniStageLoadConfig: class OmniStageLoadConfig:
"""Per-stage loading behavior.""" """Per-stage loading behavior and resolved tokenizer input."""
tokenizer: str | None = None
download_dir: str | None = None
skip_tokenizer_init: bool = False
load_format: str = "auto" load_format: str = "auto"
tokenizer_mode: str = "auto" tokenizer_mode: str = "auto"
config_format: str | None = None config_format: str | None = None
@@ -297,9 +365,11 @@ class OmniStageCacheConfig:
vLLM-Omni diffusion-specific cache backends such as TeaCache and Cache-DiT. vLLM-Omni diffusion-specific cache backends such as TeaCache and Cache-DiT.
""" """
gpu_memory_utilization: float = Field(default=0.90, gt=0.0, le=1.0) kv_cache_memory_bytes: int | None = Field(default=None, ge=0)
enable_prefix_caching: bool = False # None preserves backend-owned defaults; explicit values still project.
disable_hybrid_kv_cache_manager: bool = False gpu_memory_utilization: float | None = Field(default=None, gt=0.0, le=1.0)
enable_prefix_caching: bool | None = None
disable_hybrid_kv_cache_manager: bool | None = None
mm_processor_cache_gb: float | None = Field(default=None, ge=0.0) mm_processor_cache_gb: float | None = Field(default=None, ge=0.0)
@@ -307,14 +377,18 @@ class OmniStageCacheConfig:
class OmniStageSchedulerConfig: class OmniStageSchedulerConfig:
"""Per-stage request scheduling behavior.""" """Per-stage request scheduling behavior."""
max_num_seqs: int = Field(default=128, ge=1) max_num_seqs: int | None = Field(default=None, ge=1)
max_num_batched_tokens: int | None = Field(default=None, ge=1) max_num_batched_tokens: int | None = Field(default=None, ge=1)
max_model_len: int | None = Field(default=None, ge=-1) max_model_len: int | None = Field(default=None, ge=-1)
enable_chunked_prefill: bool = False enable_chunked_prefill: bool | None = None
async_scheduling: bool = True async_scheduling: bool | None = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.max_num_batched_tokens is not None and self.max_num_batched_tokens < self.max_num_seqs: if (
self.max_num_batched_tokens is not None
and self.max_num_seqs is not None
and self.max_num_batched_tokens < self.max_num_seqs
):
raise ValueError( raise ValueError(
f"max_num_batched_tokens ({self.max_num_batched_tokens}) must be >= max_num_seqs ({self.max_num_seqs})" f"max_num_batched_tokens ({self.max_num_batched_tokens}) must be >= max_num_seqs ({self.max_num_seqs})"
) )
@@ -322,8 +396,10 @@ class OmniStageSchedulerConfig:
@config @config
class OmniStageConnectorConfig: class OmniStageConnectorConfig:
"""Per-stage inter-stage connector wiring.""" """Per-stage connector wiring and resolved transfer mode."""
async_chunk: bool = False
omni_kv_config: dict[str, Any] | None = None
stage_connector: dict[str, Any] = field( stage_connector: dict[str, Any] = field(
default_factory=lambda: { default_factory=lambda: {
"name": "SharedMemoryConnector", "name": "SharedMemoryConnector",
@@ -336,8 +412,10 @@ class OmniStageConnectorConfig:
@config @config
class OmniStageRuntimeConfig: class OmniStageRuntimeConfig:
"""Per-stage process placement and runtime behavior.""" """Per-stage process placement and backend runtime behavior."""
distributed_executor_backend: Any = None
worker_cls: str | None = None
devices: str | None = None devices: str | None = None
num_replicas: int = Field(default=1, ge=1) num_replicas: int = Field(default=1, ge=1)
env: dict[str, Any] | None = None env: dict[str, Any] | None = None
@@ -448,7 +526,12 @@ class _DiffusionConfigProjection:
stage_id: int = 0 stage_id: int = 0
model: str | None = None model: str | None = None
model_class_name: str | None = None model_class_name: str | None = None
engine_backend: str | type = "default"
diffusion_model_runner_cls: str | type | None = None
request_batch_max_wait_ms: float = 0.0
streaming_output: bool = False
model_arch: str | None = None model_arch: str | None = None
task_type: str | None = None
dtype: Any = "auto" dtype: Any = "auto"
trust_remote_code: bool = False trust_remote_code: bool = False
revision: str | None = None revision: str | None = None
@@ -456,6 +539,7 @@ class _DiffusionConfigProjection:
dist_timeout: int | None = None dist_timeout: int | None = None
nccl_port: int | None = None nccl_port: int | None = None
master_port: int | None = None master_port: int | None = None
scheduler_port: int | None = None
host: str | None = None host: str | None = None
port: int | None = None port: int | None = None
model_config: dict[str, Any] = field(default_factory=dict) model_config: dict[str, Any] = field(default_factory=dict)
@@ -467,6 +551,7 @@ class _DiffusionConfigProjection:
enable_cache_dit_summary: bool = False enable_cache_dit_summary: bool = False
enable_prompt_embed_cache: bool = False enable_prompt_embed_cache: bool = False
prompt_embed_cache_size: int = Field(default=32, ge=1) prompt_embed_cache_size: int = Field(default=32, ge=1)
enable_session_state_manager: bool = False
diffusion_load_format: str = "default" diffusion_load_format: str = "default"
diffusers_load_kwargs: dict[str, Any] = field(default_factory=dict) diffusers_load_kwargs: dict[str, Any] = field(default_factory=dict)
diffusers_call_kwargs: dict[str, Any] = field(default_factory=dict) diffusers_call_kwargs: dict[str, Any] = field(default_factory=dict)
@@ -502,6 +587,7 @@ class _DiffusionConfigProjection:
step_execution: bool = False step_execution: bool = False
supports_multimodal_inputs: bool = False supports_multimodal_inputs: bool = False
max_multimodal_image_inputs: int | None = None max_multimodal_image_inputs: int | None = None
supports_mixed_reference_inputs: bool = False
model_paths: dict[str, str] = field(default_factory=dict) model_paths: dict[str, str] = field(default_factory=dict)
model_loaded: dict[str, bool] = field( model_loaded: dict[str, bool] = field(
default_factory=lambda: { default_factory=lambda: {
@@ -679,6 +765,7 @@ _DIFFUSION_SHARED_CONFIG_FIELDS = frozenset(
"stage_id", "stage_id",
"model", "model",
"model_arch", "model_arch",
"task_type",
"dtype", "dtype",
"trust_remote_code", "trust_remote_code",
"revision", "revision",
@@ -694,6 +781,7 @@ _DIFFUSION_RUNTIME_CONFIG_FIELDS = frozenset(
"port", "port",
"nccl_port", "nccl_port",
"master_port", "master_port",
"scheduler_port",
"worker_extension_cls", "worker_extension_cls",
"enable_stage_verification", "enable_stage_verification",
"prompt_file_path", "prompt_file_path",
@@ -741,21 +829,85 @@ _MODEL_ENGINE_FIELDS = frozenset(_ModelEngineOverrides.__annotations__)
_LOAD_ENGINE_FIELDS = frozenset(_LoadEngineOverrides.__annotations__) _LOAD_ENGINE_FIELDS = frozenset(_LoadEngineOverrides.__annotations__)
_CACHE_ENGINE_FIELDS = frozenset(_CacheEngineOverrides.__annotations__) _CACHE_ENGINE_FIELDS = frozenset(_CacheEngineOverrides.__annotations__)
_SCHEDULER_ENGINE_FIELDS = frozenset(_SchedulerEngineOverrides.__annotations__) _SCHEDULER_ENGINE_FIELDS = frozenset(_SchedulerEngineOverrides.__annotations__)
_CONNECTOR_ENGINE_FIELDS = frozenset(_ConnectorEngineOverrides.__annotations__)
_RUNTIME_ENGINE_FIELDS = frozenset(_RuntimeEngineOverrides.__annotations__) _RUNTIME_ENGINE_FIELDS = frozenset(_RuntimeEngineOverrides.__annotations__)
_PARALLEL_CONFIG_ENGINE_FIELDS = frozenset(_ParallelConfigEngineOverrides.__annotations__) _PARALLEL_CONFIG_ENGINE_FIELDS = frozenset(_ParallelConfigEngineOverrides.__annotations__)
_PARALLEL_ENGINE_FIELDS = _PARALLEL_CONFIG_ENGINE_FIELDS | {"parallel_config"} _PARALLEL_ENGINE_FIELDS = _PARALLEL_CONFIG_ENGINE_FIELDS | {"parallel_config"}
_COMMON_STAGE_ENGINE_FIELDS = (
_QUANTIZATION_ENGINE_FIELDS
| _MODEL_ENGINE_FIELDS
| _LOAD_ENGINE_FIELDS
| _CACHE_ENGINE_FIELDS
| _SCHEDULER_ENGINE_FIELDS
| _CONNECTOR_ENGINE_FIELDS
| _RUNTIME_ENGINE_FIELDS
)
_LLM_PARALLEL_CONFIG_ENGINE_FIELDS = frozenset(f.name for f in fields(OmniStageParallelConfig)) - {"world_size"}
_DIFFUSION_PARALLEL_CONFIG_ENGINE_FIELDS = frozenset(f.name for f in fields(OmniStageDiffusionParallelConfig)) - {
"world_size"
}
_LLM_STAGE_ENGINE_FIELDS = _COMMON_STAGE_ENGINE_FIELDS | _LLM_PARALLEL_CONFIG_ENGINE_FIELDS | {"parallel_config"}
_DIFFUSION_OWNED_STAGE_ENGINE_FIELDS = (
_COMMON_STAGE_ENGINE_FIELDS
| _DIFFUSION_PARALLEL_CONFIG_ENGINE_FIELDS
| _DIFFUSION_STAGE_ENGINE_FIELDS
| {"parallel_config"}
)
# This all-stage union is used only to discover global CLI candidates. Stage
# validation must use the execution-type-specific sets below.
_STAGE_ENGINE_FIELDS = _LLM_STAGE_ENGINE_FIELDS | _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS
_STAGE_ENGINE_FIELDS_BY_EXECUTION_TYPE = {
StageExecutionType.LLM_AR: _LLM_STAGE_ENGINE_FIELDS,
StageExecutionType.LLM_GENERATION: _LLM_STAGE_ENGINE_FIELDS,
StageExecutionType.DIFFUSION: _DIFFUSION_OWNED_STAGE_ENGINE_FIELDS,
}
_PARALLEL_CONFIG_ENGINE_FIELDS_BY_EXECUTION_TYPE = {
StageExecutionType.LLM_AR: _LLM_PARALLEL_CONFIG_ENGINE_FIELDS,
StageExecutionType.LLM_GENERATION: _LLM_PARALLEL_CONFIG_ENGINE_FIELDS,
StageExecutionType.DIFFUSION: _DIFFUSION_PARALLEL_CONFIG_ENGINE_FIELDS,
}
def _validate_stage_engine_override_ownership(
stage_id: int,
execution_type: StageExecutionType,
overrides: Mapping[str, Any],
) -> None:
try:
owner_fields = _STAGE_ENGINE_FIELDS_BY_EXECUTION_TYPE[execution_type]
parallel_owner_fields = _PARALLEL_CONFIG_ENGINE_FIELDS_BY_EXECUTION_TYPE[execution_type]
except KeyError as exc:
raise ValueError(f"Unsupported stage execution type: {execution_type!r}") from exc
unowned_fields = set(overrides) - owner_fields
parallel_config = overrides.get("parallel_config")
if isinstance(parallel_config, Mapping):
unowned_fields.update(f"parallel_config.{name}" for name in set(parallel_config) - parallel_owner_fields)
if unowned_fields:
names = ", ".join(sorted(unowned_fields))
raise ValueError(
f"Stage {stage_id} ({execution_type.value}) has explicit engine argument(s) "
f"with no structured config owner: {names}"
)
def _global_stage_cli_fields() -> frozenset[str]: def _global_stage_cli_fields() -> frozenset[str]:
# Lazy import avoids vllm_omni.config -> omni_config -> engine.arg_utils -> # Lazy import avoids vllm_omni.config -> omni_config -> engine.arg_utils ->
# vllm_omni.config during package-level config imports. # vllm_omni.config during package-level config imports.
from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.engine.arg_utils import OmniEngineArgs, orchestrator_field_names
return ( candidates = (
frozenset(f.name for f in fields(OmniEngineArgs)) frozenset(f.name for f in fields(OmniEngineArgs))
| frozenset(_STAGE_DEPLOY_ENGINE_FIELDS) | frozenset(_STAGE_DEPLOY_ENGINE_FIELDS)
| frozenset(_PIPELINE_DEPLOY_CLI_FIELDS) | frozenset(_PIPELINE_DEPLOY_CLI_FIELDS)
) - {"model", "stage_id", "stage_configs_path", "async_chunk"} )
externally_consumed = (
_NON_STAGE_ENGINE_CLI_FIELDS
| frozenset(f.name for f in fields(VllmOmniOrchestratorConfig))
| (orchestrator_field_names() - _STAGE_ENGINE_FIELDS)
)
return candidates - externally_consumed
def _mapping_or_empty(value: Any) -> Mapping[str, Any]: def _mapping_or_empty(value: Any) -> Mapping[str, Any]:
@@ -775,17 +927,33 @@ def _stage_engine_overrides(stage_deploy: StageDeployConfig | None) -> dict[str,
value = getattr(stage_deploy, name) value = getattr(stage_deploy, name)
if value is not None: if value is not None:
overrides[name] = _copy_value(value) overrides[name] = _copy_value(value)
overrides.update(_copy_value(stage_deploy.engine_extras)) overrides.update(
{
name: _copy_value(value)
for name, value in stage_deploy.engine_extras.items()
if name not in _LEGACY_STAGE_METADATA_EXTRA_FIELDS
}
)
return overrides return overrides
def _stage_engine_values( def _stage_engine_values(
stage_deploy: StageDeployConfig | None, stage_deploy: StageDeployConfig | None,
topology: StagePipelineConfig,
stage_cli_overrides: Mapping[str, Any] | None = None, stage_cli_overrides: Mapping[str, Any] | None = None,
) -> _StageEngineValues: ) -> _StageEngineValues:
engine = _stage_engine_overrides(stage_deploy) engine = _stage_engine_overrides(stage_deploy)
# Preserve legacy ordering: topology-owned KV roles override deploy
# extras, while an explicit CLI override remains highest priority.
if topology.omni_kv_config:
engine["omni_kv_config"] = _copy_value(topology.omni_kv_config)
if stage_cli_overrides: if stage_cli_overrides:
engine.update(_copy_value(stage_cli_overrides)) engine.update(_copy_value(stage_cli_overrides))
_validate_stage_engine_override_ownership(
topology.stage_id,
topology.execution_type,
engine,
)
return _StageEngineValues( return _StageEngineValues(
quantization=cast( quantization=cast(
_QuantizationEngineOverrides, _QuantizationEngineOverrides,
@@ -798,6 +966,10 @@ def _stage_engine_values(
_SchedulerEngineOverrides, _SchedulerEngineOverrides,
_select_engine_overrides(engine, _SCHEDULER_ENGINE_FIELDS), _select_engine_overrides(engine, _SCHEDULER_ENGINE_FIELDS),
), ),
connector=cast(
_ConnectorEngineOverrides,
_select_engine_overrides(engine, _CONNECTOR_ENGINE_FIELDS),
),
runtime=cast(_RuntimeEngineOverrides, _select_engine_overrides(engine, _RUNTIME_ENGINE_FIELDS)), runtime=cast(_RuntimeEngineOverrides, _select_engine_overrides(engine, _RUNTIME_ENGINE_FIELDS)),
parallel=cast(_ParallelEngineOverrides, _select_engine_overrides(engine, _PARALLEL_ENGINE_FIELDS)), parallel=cast(_ParallelEngineOverrides, _select_engine_overrides(engine, _PARALLEL_ENGINE_FIELDS)),
diffusion=_DiffusionEngineOverrides.from_engine(engine), diffusion=_DiffusionEngineOverrides.from_engine(engine),
@@ -893,9 +1065,10 @@ class BaseVllmOmniStageConfig:
@property @property
def scheduler_cls(self) -> str | None: def scheduler_cls(self) -> str | None:
return _resolve_scheduler_path( async_scheduling = self.scheduler_config.async_scheduling
return self.stage_pipeline_config.scheduler_cls or _resolve_scheduler_path(
self.stage_pipeline_config.execution_type, self.stage_pipeline_config.execution_type,
self.scheduler_config.async_scheduling, True if async_scheduling is None else async_scheduling,
) )
@property @property
@@ -957,11 +1130,14 @@ StageConfigType: TypeAlias = VllmOmniARStageConfig | VllmOmniGenerationStageConf
def _build_common_stage_config_kwargs( def _build_common_stage_config_kwargs(
pipeline: PipelineConfig,
deploy: DeployConfig, deploy: DeployConfig,
topology: StagePipelineConfig, topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None, stage_deploy: StageDeployConfig | None,
engine: _StageEngineValues, engine: _StageEngineValues,
parallel_config_cls: type[OmniStageParallelConfig] = OmniStageParallelConfig, parallel_config_cls: type[OmniStageParallelConfig] = OmniStageParallelConfig,
*,
model: str | None,
) -> tuple[dict[str, Any], str | None, str | None]: ) -> tuple[dict[str, Any], str | None, str | None]:
input_proc, next_stage_proc = _select_processor_funcs(topology, bool(deploy.async_chunk)) input_proc, next_stage_proc = _select_processor_funcs(topology, bool(deploy.async_chunk))
quantization_config = _build_quantization_config(deploy, engine.quantization) quantization_config = _build_quantization_config(deploy, engine.quantization)
@@ -971,16 +1147,36 @@ def _build_common_stage_config_kwargs(
{ {
"stage_pipeline_config": topology, "stage_pipeline_config": topology,
"model_config": _build_model_config( "model_config": _build_model_config(
pipeline,
deploy,
topology, topology,
stage_deploy, stage_deploy,
engine.model, engine.model,
duplex_max_sessions=(deploy.duplex_session.max_sessions if deploy.session_mode == "duplex" else 1), duplex_max_sessions=(deploy.duplex_session.max_sessions if deploy.session_mode == "duplex" else 1),
model=model,
),
"load_config": _build_load_config(topology, engine.load),
"cache_config": _build_cache_config(
deploy,
engine.cache,
topology.execution_type,
),
"scheduler_config": _build_scheduler_config(
deploy,
engine.scheduler,
topology.execution_type,
),
"connector_config": _build_connector_config(
deploy,
stage_deploy,
engine.connector,
),
"runtime_config": _build_runtime_config(
deploy,
stage_deploy,
engine.runtime,
parallel_config,
), ),
"load_config": _build_load_config(engine.load),
"cache_config": _build_cache_config(deploy, engine.cache),
"scheduler_config": _build_scheduler_config(deploy, engine.scheduler),
"connector_config": _build_connector_config(stage_deploy),
"runtime_config": _build_runtime_config(stage_deploy, engine.runtime, parallel_config),
"parallel_config": parallel_config, "parallel_config": parallel_config,
"quantization_config": _copy_value(quantization_config), "quantization_config": _copy_value(quantization_config),
}, },
@@ -1009,10 +1205,12 @@ def _build_ar_stage_config(
model: str | None, model: str | None,
) -> VllmOmniARStageConfig: ) -> VllmOmniARStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs( common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy, deploy,
topology, topology,
stage_deploy, stage_deploy,
engine, engine,
model=model,
) )
return cast( return cast(
VllmOmniARStageConfig, VllmOmniARStageConfig,
@@ -1034,10 +1232,12 @@ def _build_generation_stage_config(
model: str | None, model: str | None,
) -> VllmOmniGenerationStageConfig: ) -> VllmOmniGenerationStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs( common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy, deploy,
topology, topology,
stage_deploy, stage_deploy,
engine, engine,
model=model,
) )
return cast( return cast(
VllmOmniGenerationStageConfig, VllmOmniGenerationStageConfig,
@@ -1059,18 +1259,20 @@ def _build_diffusion_stage_config(
model: str | None, model: str | None,
) -> VllmOmniDiffusionStageConfig: ) -> VllmOmniDiffusionStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs( common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy, deploy,
topology, topology,
stage_deploy, stage_deploy,
engine, engine,
OmniStageDiffusionParallelConfig, OmniStageDiffusionParallelConfig,
model=model,
) )
common_kwargs["diffusion_config"] = _build_diffusion_config_projection( common_kwargs["diffusion_config"] = _build_diffusion_config_projection(
pipeline, pipeline,
deploy, deploy,
topology, topology,
engine.diffusion, engine.diffusion,
model=model, model=common_kwargs["model_config"].model,
quantization_config=common_kwargs["quantization_config"], quantization_config=common_kwargs["quantization_config"],
) )
return cast( return cast(
@@ -1125,14 +1327,28 @@ def _build_quantization_config(
def _build_model_config( def _build_model_config(
pipeline: PipelineConfig,
deploy: DeployConfig,
topology: StagePipelineConfig, topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None, stage_deploy: StageDeployConfig | None,
engine: _ModelEngineOverrides, engine: _ModelEngineOverrides,
*, *,
duplex_max_sessions: int, duplex_max_sessions: int,
model: str | None,
) -> OmniStageModelConfig: ) -> OmniStageModelConfig:
default_sampling_params = _stage_sampling_params(stage_deploy, topology) default_sampling_params = _stage_sampling_params(stage_deploy, topology)
kwargs = _config_kwargs(engine) kwargs = _config_kwargs(engine)
kwargs["model"] = _first_defined(kwargs.get("model"), model)
if "model_arch" not in kwargs:
kwargs["model_arch"] = topology.model_arch or pipeline.model_arch or None
if "trust_remote_code" not in kwargs and deploy.trust_remote_code is not None:
kwargs["trust_remote_code"] = _copy_value(deploy.trust_remote_code)
if "dtype" not in kwargs and deploy.dtype is not None:
kwargs["dtype"] = _copy_value(deploy.dtype)
if "active_stream_window" not in kwargs:
kwargs["active_stream_window"] = _copy_value(deploy.active_stream_window)
if "custom_voice_dir" not in kwargs and deploy.custom_voice_dir is not None:
kwargs["custom_voice_dir"] = _copy_value(deploy.custom_voice_dir)
if "has_sampling_extra_args" not in kwargs: if "has_sampling_extra_args" not in kwargs:
kwargs["has_sampling_extra_args"] = bool((default_sampling_params or {}).get("extra_args")) kwargs["has_sampling_extra_args"] = bool((default_sampling_params or {}).get("extra_args"))
if "model_subdir" not in kwargs and topology.model_subdir is not None: if "model_subdir" not in kwargs and topology.model_subdir is not None:
@@ -1146,45 +1362,67 @@ def _build_model_config(
) )
def _build_load_config(engine: _LoadEngineOverrides) -> OmniStageLoadConfig: def _build_load_config(
return OmniStageLoadConfig(**_config_kwargs(engine)) topology: StagePipelineConfig,
engine: _LoadEngineOverrides,
) -> OmniStageLoadConfig:
kwargs = _config_kwargs(engine)
if "skip_mm_profiling" not in kwargs and not topology.requires_multimodal_data:
kwargs["skip_mm_profiling"] = True
return OmniStageLoadConfig(**kwargs)
def _build_cache_config( def _build_cache_config(
deploy: DeployConfig, deploy: DeployConfig,
engine: _CacheEngineOverrides, engine: _CacheEngineOverrides,
execution_type: StageExecutionType,
) -> OmniStageCacheConfig: ) -> OmniStageCacheConfig:
kwargs = _config_kwargs(engine) kwargs = _config_kwargs(engine)
if "enable_prefix_caching" not in kwargs and deploy.enable_prefix_caching is not None: if "enable_prefix_caching" not in kwargs and deploy.enable_prefix_caching is not None:
kwargs["enable_prefix_caching"] = _copy_value(deploy.enable_prefix_caching) kwargs["enable_prefix_caching"] = _copy_value(deploy.enable_prefix_caching)
if "disable_hybrid_kv_cache_manager" not in kwargs and execution_type == StageExecutionType.LLM_GENERATION:
# Match the generation-stage override applied by legacy finalization.
kwargs["disable_hybrid_kv_cache_manager"] = True
return OmniStageCacheConfig(**kwargs) return OmniStageCacheConfig(**kwargs)
def _build_scheduler_config( def _build_scheduler_config(
deploy: DeployConfig, deploy: DeployConfig,
engine: _SchedulerEngineOverrides, engine: _SchedulerEngineOverrides,
execution_type: StageExecutionType,
) -> OmniStageSchedulerConfig: ) -> OmniStageSchedulerConfig:
kwargs = _config_kwargs(engine) kwargs = _config_kwargs(engine)
if "enable_chunked_prefill" not in kwargs and deploy.enable_chunked_prefill is not None: if "enable_chunked_prefill" not in kwargs and deploy.enable_chunked_prefill is not None:
kwargs["enable_chunked_prefill"] = _copy_value(deploy.enable_chunked_prefill) kwargs["enable_chunked_prefill"] = _copy_value(deploy.enable_chunked_prefill)
if "async_scheduling" not in kwargs and execution_type == StageExecutionType.LLM_AR:
kwargs["async_scheduling"] = True
return OmniStageSchedulerConfig(**kwargs) return OmniStageSchedulerConfig(**kwargs)
def _build_connector_config(stage_deploy: StageDeployConfig | None) -> OmniStageConnectorConfig: def _build_connector_config(
deploy: DeployConfig,
stage_deploy: StageDeployConfig | None,
engine: _ConnectorEngineOverrides,
) -> OmniStageConnectorConfig:
output_connectors = stage_deploy.output_connectors if stage_deploy is not None else None output_connectors = stage_deploy.output_connectors if stage_deploy is not None else None
input_connectors = stage_deploy.input_connectors if stage_deploy is not None else None input_connectors = stage_deploy.input_connectors if stage_deploy is not None else None
return OmniStageConnectorConfig( return OmniStageConnectorConfig(
async_chunk=bool(deploy.async_chunk),
omni_kv_config=_copy_value(engine.get("omni_kv_config")),
output_connectors=_copy_value(output_connectors) if output_connectors else None, output_connectors=_copy_value(output_connectors) if output_connectors else None,
input_connectors=_copy_value(input_connectors) if input_connectors else None, input_connectors=_copy_value(input_connectors) if input_connectors else None,
) )
def _build_runtime_config( def _build_runtime_config(
deploy: DeployConfig,
stage_deploy: StageDeployConfig | None, stage_deploy: StageDeployConfig | None,
engine: _RuntimeEngineOverrides, engine: _RuntimeEngineOverrides,
parallel_config: OmniStageParallelConfig, parallel_config: OmniStageParallelConfig,
) -> OmniStageRuntimeConfig: ) -> OmniStageRuntimeConfig:
kwargs = _config_kwargs(engine) kwargs = _config_kwargs(engine)
if "distributed_executor_backend" not in kwargs and deploy.distributed_executor_backend is not None:
kwargs["distributed_executor_backend"] = _copy_value(deploy.distributed_executor_backend)
if "devices" not in kwargs and stage_deploy is not None and stage_deploy.devices is not None: if "devices" not in kwargs and stage_deploy is not None and stage_deploy.devices is not None:
kwargs["devices"] = _copy_value(stage_deploy.devices) kwargs["devices"] = _copy_value(stage_deploy.devices)
if "num_replicas" not in kwargs and stage_deploy is not None: if "num_replicas" not in kwargs and stage_deploy is not None:
@@ -1234,13 +1472,15 @@ def _build_diffusion_config_projection(
topology.model_arch, topology.model_arch,
pipeline.model_arch, pipeline.model_arch,
) )
if "model_class_name" not in diffusion_kwargs and topology.model_arch is not None:
diffusion_kwargs["model_class_name"] = _copy_value(topology.model_arch)
if "dtype" not in diffusion_kwargs and deploy.dtype is not None: if "dtype" not in diffusion_kwargs and deploy.dtype is not None:
diffusion_kwargs["dtype"] = _copy_value(deploy.dtype) diffusion_kwargs["dtype"] = _copy_value(deploy.dtype)
if "trust_remote_code" not in diffusion_kwargs and deploy.trust_remote_code is not None: if "trust_remote_code" not in diffusion_kwargs and deploy.trust_remote_code is not None:
diffusion_kwargs["trust_remote_code"] = _copy_value(deploy.trust_remote_code) diffusion_kwargs["trust_remote_code"] = _copy_value(deploy.trust_remote_code)
if "distributed_executor_backend" not in diffusion_kwargs and deploy.distributed_executor_backend is not None: if "distributed_executor_backend" not in diffusion_kwargs and deploy.distributed_executor_backend is not None:
diffusion_kwargs["distributed_executor_backend"] = _copy_value(deploy.distributed_executor_backend) diffusion_kwargs["distributed_executor_backend"] = _copy_value(deploy.distributed_executor_backend)
if model is not None: if "model" not in diffusion_kwargs and model is not None:
diffusion_kwargs["model"] = model diffusion_kwargs["model"] = model
if quantization_config is not None: if quantization_config is not None:
diffusion_kwargs["quantization_config"] = _copy_value(quantization_config) diffusion_kwargs["quantization_config"] = _copy_value(quantization_config)
@@ -1302,6 +1542,7 @@ class VllmOmniConfig:
deploy_by_id.get(topology.stage_id), deploy_by_id.get(topology.stage_id),
_stage_engine_values( _stage_engine_values(
deploy_by_id.get(topology.stage_id), deploy_by_id.get(topology.stage_id),
topology,
_stage_cli_overrides(topology.stage_id, cli_overrides), _stage_cli_overrides(topology.stage_id, cli_overrides),
), ),
model=model, model=model,
@@ -20,6 +20,21 @@ class AttentionBackend(ABC):
# avoid a slower masked-attention plan when tail padding is not semantic. # avoid a slower masked-attention plan when tail padding is not semantic.
supports_prefix_kv_slicing: bool = False supports_prefix_kv_slicing: bool = False
# ``OmniPlatformEnum`` values this backend runs on; None means unrestricted.
# Platform resolution rejects an explicit selection outside this set, so a
# hardware-specific backend fails before the model is built.
supported_platforms: tuple[str, ...] | None = None
@classmethod
def validate_available(cls) -> None:
"""Raise if this backend's optional dependencies are missing.
Called during platform resolution, i.e. before model construction, so a
backend that probes its kernel package lazily still reports the problem
while the user can still act on it.
"""
return None
@classmethod @classmethod
def supports_attention_mask(cls) -> bool: def supports_attention_mask(cls) -> bool:
return False return False
@@ -63,6 +78,23 @@ class QueryRange:
global_start: int global_start: int
@dataclass(frozen=True, slots=True)
class VideoTokenLayout:
"""Where the video segment sits inside a packed multimodal sequence.
A model that packs its sequence as ``[prefix | t*h*w video rows | padding]``
publishes this so backends can recover spatiotemporal locality; the prefix
holds everything that is not video (text, visual conditions, audio).
Publishing it also asserts that any ``attn_mask`` masks only the trailing
padding, so ``prefix_len + t*h*w`` is the used length of the sequence.
Plain ints, so reading it never forces a device-to-host sync.
"""
prefix_len: int
latent_grid: tuple[int, int, int]
@dataclass @dataclass
class AttentionMetadata: class AttentionMetadata:
attn_mask: torch.Tensor | None = None attn_mask: torch.Tensor | None = None
@@ -95,6 +127,10 @@ class AttentionMetadata:
full_attn_spans: list[list[tuple[int, int]]] | None = None full_attn_spans: list[list[tuple[int, int]]] | None = None
query_ranges: tuple[QueryRange, ...] | None = None query_ranges: tuple[QueryRange, ...] | None = None
# Geometry of the video segment for backends that exploit spatiotemporal
# locality (block-sparse selection, tiled masks). Dense backends ignore it.
video_layout: VideoTokenLayout | None = None
T = TypeVar("T", bound=AttentionMetadata) T = TypeVar("T", bound=AttentionMetadata)
@@ -0,0 +1,366 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
import torch
from vllm.logger import init_logger
from vllm.model_executor.models.utils import extract_layer_index
from vllm_omni.diffusion.attention.backends.abstract import (
AttentionBackend,
AttentionImpl,
AttentionMetadata,
)
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionBackend
from vllm_omni.diffusion.config import get_current_diffusion_config_or_none
from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available
logger = init_logger(__name__)
# The rf_v2 kernel only implements a 128-token block.
_BLOCK_SIZE = 128
# Below this many video blocks, the pooling and gather that block selection adds
# cost more than the QK work it removes, so stay dense.
_MIN_VIDEO_BLOCKS = 32
# rf_v2's ``input_layout`` describes the caller's tensors, and everything below
# slices the sequence on dim 1. vLLM-Omni diffusion attention hands the impl
# [B, S, N, D], so that is the only layout this backend accepts.
_INPUT_LAYOUT = "BSND"
# rf_v2 precision mode: 0 = high precision, 1 = high performance. Kept at the
# precise setting because the sparsity knob is the intended perf lever, and on
# A5 devices the kernel is routed to rf_v3, which overrides this anyway.
_INNER_PRECISE = 0
_WRONG_PLATFORM = (
"RAINFUSION_ATTN runs the MindIE-SD rf_v2 kernel and is available on Ascend NPU only. "
"Select FLASH_ATTN or TORCH_SDPA on this platform."
)
_MISSING_MINDIESD = (
"RAINFUSION_ATTN requires MindIE-SD. Please install MindIE-SD to enable RainFusion sparse "
"attention on Ascend NPU. For installation details, see https://gitcode.com/Ascend/MindIE-SD "
"Otherwise, use FlashAttention by setting DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN"
)
def _try_extract_layer_index(prefix: str) -> int | None:
if not prefix:
return None
try:
return extract_layer_index(prefix)
except (AssertionError, ValueError):
return None
@dataclass(frozen=True)
class RainFusionConfig:
"""Resolved RainFusion controls for one attention layer.
``sparsity`` is the nominal fraction of key blocks dropped per query block.
The realized sparsity is lower because rf_v2 always keeps the prefix rows and
the first-frame blocks. ``start_step`` and ``skip_layers`` are the accuracy
knobs: early denoise steps and specific DiT blocks stay dense.
"""
sparsity: float = 0.0
start_step: int = 0
skip_layers: frozenset[int] = frozenset()
@classmethod
def from_backend_kwargs(cls, backend_kwargs: dict | None) -> RainFusionConfig:
bk = backend_kwargs or {}
return cls(
sparsity=float(bk.get("sparsity", 0.0)),
start_step=int(bk.get("start_step", 0)),
skip_layers=frozenset(bk.get("skip_layers") or ()),
)
@property
def enabled(self) -> bool:
return self.sparsity > 0.0
@dataclass(frozen=True)
class RainFusionPlan:
"""Per-forward geometry handed to the rf_v2 kernel."""
prefix_len: int
used_len: int
latent_shape: list[int]
class RainFusionAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
supported_platforms: tuple[str, ...] = ("npu",)
@classmethod
def validate_available(cls) -> None:
from importlib.util import find_spec
if find_spec("mindiesd") is None:
raise ValueError(_MISSING_MINDIESD)
@staticmethod
def get_supported_head_sizes() -> list[int]:
return [64, 96, 128, 192, 256]
@staticmethod
def get_name() -> str:
return "RAINFUSION_ATTN"
@staticmethod
def get_impl_cls() -> type[RainFusionAttentionImpl]:
return RainFusionAttentionImpl
class RainFusionAttentionImpl(AttentionImpl):
"""Block-sparse video attention via MindIE-SD RainFusion (rf_v2) on Ascend NPU.
Sparsity applies only to the video segment of a packed multimodal sequence,
whose extent the model publishes as ``AttentionMetadata.video_layout``. Every
other case — warmup denoise steps, exempt layers, a layer that does not declare
``qkv_layout="BSND"``, sequences without a published video segment, video
segments too short to pay for block selection or not aligned to the kernel's
block size — delegates to FlashAttention, so a model can select this backend
unconditionally.
"""
def __init__(
self,
num_heads: int,
head_size: int,
softmax_scale: float,
causal: bool = False,
num_kv_heads: int | None = None,
prefix: str = "",
qkv_layout: str | None = None,
backend_kwargs: dict[str, Any] | None = None,
**extra_impl_args,
) -> None:
self.num_heads = num_heads
self.causal = causal
self.softmax_scale = softmax_scale
self.qkv_layout = qkv_layout
self.rainfusion = RainFusionConfig.from_backend_kwargs(backend_kwargs)
self.layer_idx = _try_extract_layer_index(prefix)
if self.rainfusion.enabled:
self._validate_parallel_config()
if causal:
raise ValueError(
"RAINFUSION_ATTN does not support causal attention: rf_v2 selects key "
"blocks by pooled relevance and cannot express a causal mask. Select "
"FLASH_ATTN for causal roles."
)
if qkv_layout is not None and qkv_layout.upper() != _INPUT_LAYOUT:
raise ValueError(
f"RAINFUSION_ATTN needs {_INPUT_LAYOUT} tensors to locate the video segment along "
f"the sequence axis, but this layer declares qkv_layout={qkv_layout!r}. Select "
"FLASH_ATTN for this role."
)
self.dense_fallback = FlashAttentionBackend.get_impl_cls()(
num_heads=num_heads,
head_size=head_size,
softmax_scale=softmax_scale,
causal=causal,
num_kv_heads=num_kv_heads,
prefix=prefix,
qkv_layout=qkv_layout,
)
def _validate_parallel_config(self) -> None:
config = get_current_diffusion_config_or_none()
parallel_config = getattr(config, "parallel_config", None)
ring_degree = getattr(parallel_config, "ring_degree", 1)
if ring_degree > 1:
# Ring gives each rank a slice of the sequence, so block selection
# would score only local keys and the layer bypasses the backend
# entirely (see Attention._run_ring_attention).
raise ValueError(
"RAINFUSION_ATTN is not compatible with ring sequence parallelism "
f"(ring_degree={ring_degree}): rf_v2 needs the whole key sequence to rank "
"blocks. Use Ulysses SP (ring_degree=1) instead."
)
def forward_cuda(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
# ROCm and MUSA route through forward_cuda by default, so this covers them too.
raise NotImplementedError(_WRONG_PLATFORM)
def forward_xpu(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
raise NotImplementedError(_WRONG_PLATFORM)
def forward_npu(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor:
plan = self._resolve_plan(attn_metadata)
if plan is None:
return self.dense_fallback.forward_npu(query, key, value, attn_metadata)
return self._forward_sparse_npu(query, key, value, plan)
def _resolve_plan(self, attn_metadata: AttentionMetadata | None) -> RainFusionPlan | None:
"""Return the rf_v2 geometry, or None when this forward must stay dense."""
rf = self.rainfusion
if not rf.enabled:
return None
if self.layer_idx is not None and self.layer_idx in rf.skip_layers:
return None
if is_forward_context_available():
step_idx = get_forward_context().denoise_step_idx
if step_idx is not None and step_idx < rf.start_step:
return None
if self.qkv_layout is None:
# The sparse path reads the sequence off dim 1, which the tensors alone
# do not establish, and the dense fallback resolves an absent layout its
# own way. Sparsifying on an assumption would put the two paths on
# different axes, so an undeclared layout stays dense.
logger.warning_once(
"RAINFUSION_ATTN staying dense: this layer does not declare qkv_layout, and rf_v2 "
"needs %s to locate the video segment along the sequence axis. Set qkv_layout=%r on "
"the Attention layer to enable sparsity.",
_INPUT_LAYOUT,
_INPUT_LAYOUT,
)
return None
if attn_metadata is None:
return None
layout = attn_metadata.video_layout
if layout is None:
logger.warning_once(
"RAINFUSION_ATTN staying dense: this attention role carries no video segment. The "
"model must publish AttentionMetadata.video_layout for the sequence to be sparsified."
)
return None
max_seqlen_q = attn_metadata.extra.get("max_seqlen_q")
if max_seqlen_q is None:
logger.warning_once(
"RAINFUSION_ATTN staying dense: attention metadata is missing max_seqlen_q, so the "
"video segment cannot be confirmed to be the tail of packed document 0."
)
return None
prefix_len = int(layout.prefix_len)
latent_shape = [int(dim) for dim in layout.latent_grid]
# rf_v2 splits the sequence as [prefix | t*h*w video rows]. Document 0 of
# the packed sequence holds those rows; anything past it is alignment
# padding that rf_v2 must not see.
video_len = math.prod(latent_shape)
used_len = prefix_len + video_len
if used_len != int(max_seqlen_q):
logger.warning_once(
"RAINFUSION_ATTN staying dense: prefix (%d) plus latent grid %s does not fill "
"packed document 0 (%d rows). rf_v2 requires the video segment to be its tail.",
prefix_len,
tuple(latent_shape),
int(max_seqlen_q),
)
return None
if video_len < _MIN_VIDEO_BLOCKS * _BLOCK_SIZE:
logger.warning_once(
"RAINFUSION_ATTN staying dense: %d video rows is under the %d-row "
"(%d block) threshold where sparse selection pays off.",
video_len,
_MIN_VIDEO_BLOCKS * _BLOCK_SIZE,
_MIN_VIDEO_BLOCKS,
)
return None
if video_len % _BLOCK_SIZE != 0:
# rf_v2 tiles the sequence into ceil((video + prefix) / 128) blocks but
# builds its block mask from the two segments pooled apart. The counts
# only agree when the video segment ends on a block boundary; otherwise
# a prefix block is forced on out of range, and the block straddling the
# seam holds real prefix rows that selection may drop. Padding the
# sequence to realign it is not an option because rf_v2 ignores
# attn_mask, so pad keys would take a share of every softmax denominator.
logger.warning_once(
"RAINFUSION_ATTN staying dense: latent grid %s gives %d video rows, which is not a "
"multiple of the %d-row kernel block. Choose a geometry whose latent_t * h * w is a "
"multiple of %d to enable sparsity.",
tuple(latent_shape),
video_len,
_BLOCK_SIZE,
_BLOCK_SIZE,
)
return None
logger.info_once(
"RAINFUSION_ATTN active: sparsity=%.2f, start_step=%d, exempt_layers=%d, "
"latent_grid=%s, prefix_rows=%d, video_rows=%d. Realized sparsity is lower than nominal "
"because prefix and first-frame blocks are always kept.",
rf.sparsity,
rf.start_step,
len(rf.skip_layers),
tuple(latent_shape),
prefix_len,
video_len,
)
return RainFusionPlan(
prefix_len=prefix_len,
used_len=used_len,
latent_shape=latent_shape,
)
def _forward_sparse_npu(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
plan: RainFusionPlan,
) -> torch.Tensor:
try:
from mindiesd import sparse_attention
except ImportError:
raise ImportError(_MISSING_MINDIESD)
used = plan.used_len
q, k, v = (tensor[:, :used] for tensor in (query, key, value))
# Ulysses has already gathered the full sequence onto this rank and split
# the heads, so read the head count off the tensor rather than num_heads.
out = sparse_attention(
q,
k,
v,
scale=self.softmax_scale,
head_num=query.shape[-2],
input_layout=_INPUT_LAYOUT,
inner_precise=_INNER_PRECISE,
sparse_type="rf_v2",
txt_len=plan.prefix_len,
block_size=_BLOCK_SIZE,
latent_shape_q=plan.latent_shape,
latent_shape_k=plan.latent_shape,
sparsity=self.rainfusion.sparsity,
)
if used == query.shape[1]:
return out
padded = torch.zeros_like(query)
padded[:, :used] = out
return padded
@@ -65,6 +65,7 @@ class DiffusionAttentionBackendEnum(Enum, metaclass=_DiffusionBackendEnumMeta):
FLASH_ATTN_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttentionHubBackend" FLASH_ATTN_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttentionHubBackend"
FLASH_ATTN_3_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttention3HubBackend" FLASH_ATTN_3_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttention3HubBackend"
TRTLLM_ATTN = "vllm_omni.diffusion.attention.backends.trtllm_attn.TrtllmAttentionBackend" TRTLLM_ATTN = "vllm_omni.diffusion.attention.backends.trtllm_attn.TrtllmAttentionBackend"
RAINFUSION_ATTN = "vllm_omni.diffusion.attention.backends.rainfusion_attn.RainFusionAttentionBackend"
def get_path(self, include_classname: bool = True) -> str: def get_path(self, include_classname: bool = True) -> str:
"""Get the class path for this backend (respects overrides). """Get the class path for this backend (respects overrides).
@@ -180,6 +180,7 @@ class TrtllmAttentionImpl(AttentionImpl):
prefix: str = "", prefix: str = "",
qkv_layout: str | None = None, qkv_layout: str | None = None,
backend_kwargs: dict | None = None, backend_kwargs: dict | None = None,
role: str = "self",
**extra_impl_args, **extra_impl_args,
) -> None: ) -> None:
self.num_heads = num_heads self.num_heads = num_heads
@@ -187,6 +188,7 @@ class TrtllmAttentionImpl(AttentionImpl):
self.softmax_scale = softmax_scale self.softmax_scale = softmax_scale
self.causal = causal self.causal = causal
self.num_kv_heads = num_kv_heads if num_kv_heads is not None else num_heads self.num_kv_heads = num_kv_heads if num_kv_heads is not None else num_heads
self.role = role
self.skip = SkipSoftmaxConfig.from_backend_kwargs(backend_kwargs) self.skip = SkipSoftmaxConfig.from_backend_kwargs(backend_kwargs)
self._warned_missing_timestep = False self._warned_missing_timestep = False
@@ -255,8 +257,16 @@ class TrtllmAttentionImpl(AttentionImpl):
value: torch.Tensor, value: torch.Tensor,
attn_metadata: AttentionMetadata | None = None, attn_metadata: AttentionMetadata | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
extra = getattr(attn_metadata, "extra", {}) if attn_metadata is not None else {}
packed_keys = ("cu_seqlens_q", "cu_seqlens_k", "max_seqlen_q", "max_seqlen_k")
present_packed_keys = [key for key in packed_keys if key in extra]
if present_packed_keys and len(present_packed_keys) != len(packed_keys):
missing = sorted(set(packed_keys) - set(present_packed_keys))
raise ValueError(f"Incomplete packed TRTLLM attention metadata; missing {missing}")
has_packed_metadata = len(present_packed_keys) == len(packed_keys)
attn_mask = getattr(attn_metadata, "attn_mask", None) if attn_metadata is not None else None attn_mask = getattr(attn_metadata, "attn_mask", None) if attn_metadata is not None else None
if attn_mask is not None: if attn_mask is not None and not has_packed_metadata:
raise ValueError( raise ValueError(
"TRTLLM_ATTN does not support attn_mask (Wan sets one only under SP with " "TRTLLM_ATTN does not support attn_mask (Wan sets one only under SP with "
"mask_sp_padding=True and a non-divisible seq len). Either set " "mask_sp_padding=True and a non-divisible seq len). Either set "
@@ -270,30 +280,96 @@ class TrtllmAttentionImpl(AttentionImpl):
"another backend via --diffusion-attention-backend." "another backend via --diffusion-attention-backend."
) )
batch, q_len, num_q_heads, head_dim = query.shape physical_batch, q_len, num_q_heads, head_dim = query.shape
kv_len, num_kv_heads = key.shape[1], key.shape[2] kv_len, num_kv_heads = key.shape[1], key.shape[2]
device = query.device device = query.device
q = query.reshape(batch * q_len, num_q_heads, head_dim).contiguous() q = query.reshape(physical_batch * q_len, num_q_heads, head_dim).contiguous()
k = key.reshape(batch * kv_len, num_kv_heads, head_dim).contiguous() k = key.reshape(physical_batch * kv_len, num_kv_heads, head_dim).contiguous()
v = value.reshape(batch * kv_len, num_kv_heads, head_dim).contiguous() v = value.reshape(physical_batch * kv_len, num_kv_heads, head_dim).contiguous()
output_tokens = q.shape[0]
if has_packed_metadata:
cu_seq_lens_q = extra["cu_seqlens_q"]
cu_seq_lens_kv = extra["cu_seqlens_k"]
if cu_seq_lens_q.ndim != 1 or cu_seq_lens_kv.ndim != 1:
raise ValueError("Packed TRTLLM attention cu_seqlens tensors must be one-dimensional")
if cu_seq_lens_q.numel() != cu_seq_lens_kv.numel() or cu_seq_lens_q.numel() < 2:
raise ValueError("Packed TRTLLM attention requires matching non-empty Q and KV sequence batches")
if int(cu_seq_lens_q[-1].item()) != q.shape[0] or int(cu_seq_lens_kv[-1].item()) != k.shape[0]:
raise ValueError("Packed TRTLLM attention cu_seqlens must cover all Q/K/V tokens")
# Drop trailing empty sequences retained by packed producers when
# alignment adds no padding tokens.
while (
cu_seq_lens_q.numel() > 2
and int(cu_seq_lens_q[-1].item()) == int(cu_seq_lens_q[-2].item())
and int(cu_seq_lens_kv[-1].item()) == int(cu_seq_lens_kv[-2].item())
):
cu_seq_lens_q = cu_seq_lens_q[:-1]
cu_seq_lens_kv = cu_seq_lens_kv[:-1]
# A packed prefix mask denotes structural suffix padding, not another
# document. Exclude it before quantization and attention; in particular,
# SAGE block quantization must not span the valid/padding boundary.
if attn_mask is not None:
if q.shape[0] != k.shape[0] or attn_mask.numel() != q.shape[0]:
raise ValueError("Packed TRTLLM prefix masks require equal Q/K lengths")
flat_mask = attn_mask.reshape(-1).to(dtype=torch.bool)
valid_tokens = int(flat_mask.sum().item())
expected_mask = torch.arange(flat_mask.numel(), device=device) < valid_tokens
if not torch.equal(flat_mask, expected_mask):
raise ValueError("TRTLLM_ATTN only supports prefix-valid packed attention masks")
q_boundary = (cu_seq_lens_q == valid_tokens).nonzero(as_tuple=False)
kv_boundary = (cu_seq_lens_kv == valid_tokens).nonzero(as_tuple=False)
if q_boundary.numel() != 1 or kv_boundary.numel() != 1:
raise ValueError("Packed TRTLLM prefix length must be a Q/K sequence boundary")
q_end = int(q_boundary.item()) + 1
kv_end = int(kv_boundary.item()) + 1
if q_end != kv_end:
raise ValueError("Packed TRTLLM prefix masks require matching Q/K sequence batches")
q = q[:valid_tokens]
k = k[:valid_tokens]
v = v[:valid_tokens]
cu_seq_lens_q = cu_seq_lens_q[:q_end].contiguous()
cu_seq_lens_kv = cu_seq_lens_kv[:kv_end].contiguous()
batch = cu_seq_lens_q.numel() - 1
seq_lens = (cu_seq_lens_kv[1:] - cu_seq_lens_kv[:-1]).to(dtype=torch.int32).contiguous()
max_q_len = int(extra["max_seqlen_q"])
max_kv_len = int(extra["max_seqlen_k"])
else:
batch = physical_batch
seq_lens = torch.full((batch,), kv_len, dtype=torch.int32, device=device) seq_lens = torch.full((batch,), kv_len, dtype=torch.int32, device=device)
cu_seq_lens_q = torch.arange(0, (batch + 1) * q_len, step=q_len, dtype=torch.int32, device=device) cu_seq_lens_q = torch.arange(0, (batch + 1) * q_len, step=q_len, dtype=torch.int32, device=device)
cu_seq_lens_kv = torch.arange(0, (batch + 1) * kv_len, step=kv_len, dtype=torch.int32, device=device) cu_seq_lens_kv = torch.arange(0, (batch + 1) * kv_len, step=kv_len, dtype=torch.int32, device=device)
max_q_len = q_len
max_kv_len = kv_len
workspace = self._get_workspace(device) workspace = self._get_workspace(device)
bmm1_scale = self.softmax_scale bmm1_scale = self.softmax_scale
bmm2_scale = 1.0 bmm2_scale = 1.0
_skip_factor = self._resolve_skip_factor(kv_len) _skip_factor = self._resolve_skip_factor(max_kv_len)
# SAGE kwargs are only understood by newer FlashInfer builds; pass them exclusively when # SAGE kwargs are only understood by newer FlashInfer builds; pass them exclusively when
# SAGE quant is active (which already requires the kernel, checked at init) so the dense # SAGE quant is active (which already requires the kernel, checked at init) so the dense
# path stays compatible with older builds that lack these parameters. # path stays compatible with older builds that lack these parameters.
sage_kwargs: dict = {} sage_kwargs: dict = {}
if self.quant.enabled: # The SAGE kernel requires every KV sequence to contain at least one full
# quantization block. Small auxiliary attention sites use the dense kernel.
use_sage = self.quant.enabled and bool(torch.all(seq_lens >= self.quant.k_block_size).item())
if self.quant.enabled and not use_sage:
message = (
f"TRTLLM_ATTN SAGE quantization is configured for attention role {self.role!r}, but at least one "
f"KV sequence is shorter than k_block_size={self.quant.k_block_size}. Falling back to dense "
"attention for this input."
)
logger.warning_once(message)
if use_sage:
q, k, v, sage_attn_sfs, sage_block_sizes = self.quant.quantize(q, k, v, self._sage_quantize_fn) q, k, v, sage_attn_sfs, sage_block_sizes = self.quant.quantize(q, k, v, self._sage_quantize_fn)
sage_kwargs["sage_attn_sfs"] = sage_attn_sfs sage_kwargs["sage_attn_sfs"] = sage_attn_sfs
sage_kwargs["num_elts_per_sage_attn_blk"] = sage_block_sizes sage_kwargs["num_elts_per_sage_attn_blk"] = sage_block_sizes
@@ -304,8 +380,8 @@ class TrtllmAttentionImpl(AttentionImpl):
value=v, value=v,
workspace_buffer=workspace, workspace_buffer=workspace,
seq_lens=seq_lens, seq_lens=seq_lens,
max_q_len=q_len, max_q_len=max_q_len,
max_kv_len=kv_len, max_kv_len=max_kv_len,
bmm1_scale=bmm1_scale, bmm1_scale=bmm1_scale,
bmm2_scale=bmm2_scale, bmm2_scale=bmm2_scale,
o_sf_scale=-1.0, o_sf_scale=-1.0,
@@ -319,4 +395,12 @@ class TrtllmAttentionImpl(AttentionImpl):
skip_softmax_threshold_scale_factor=_skip_factor, skip_softmax_threshold_scale_factor=_skip_factor,
**sage_kwargs, **sage_kwargs,
) )
return out.reshape(batch, q_len, num_q_heads, head_dim) if out.shape[0] != output_tokens:
padded_out = torch.zeros(
(output_tokens, num_q_heads, head_dim),
dtype=out.dtype,
device=out.device,
)
padded_out[: out.shape[0]] = out
out = padded_out
return out.reshape(physical_batch, q_len, num_q_heads, head_dim)
+10
View File
@@ -89,6 +89,15 @@ class Attention(nn.Module):
role_category=role_category, role_category=role_category,
allow_trtllm_default=allow_trtllm_default, allow_trtllm_default=allow_trtllm_default,
) )
parallel_config = getattr(config, "parallel_config", None)
allgather_degree = getattr(parallel_config, "allgather_degree", 1)
# TODO: Move AllGather-KV compatibility into an AttentionBackend capability
# so validation does not depend on backend names.
if not skip_sequence_parallel and allgather_degree > 1 and attn_backend_cls.get_name() == "TRTLLM_ATTN":
raise ValueError(
"TRTLLM_ATTN does not support AllGather-KV sequence parallelism. "
"Set --allgather-degree 1 or select another diffusion attention backend."
)
if spec is not None: if spec is not None:
backend_kwargs = spec.backend_kwargs() backend_kwargs = spec.backend_kwargs()
self.backend_pref = spec.backend self.backend_pref = spec.backend
@@ -107,6 +116,7 @@ class Attention(nn.Module):
qkv_layout=qkv_layout, qkv_layout=qkv_layout,
prefix=prefix, prefix=prefix,
backend_kwargs=backend_kwargs, backend_kwargs=backend_kwargs,
role=role,
) )
# Instantiate fallback backend for float32 support # Instantiate fallback backend for float32 support
self.sdpa_fallback = SDPABackend.get_impl_cls()( self.sdpa_fallback = SDPABackend.get_impl_cls()(
+68 -27
View File
@@ -3,10 +3,13 @@
"""Generic Cache-DiT backend lifecycle and integration.""" """Generic Cache-DiT backend lifecycle and integration."""
from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from typing import Any, TypeAlias from typing import TYPE_CHECKING, Any, TypeAlias, cast
import cache_dit import cache_dit
import torch.nn as nn
from cache_dit import BlockAdapter, DBCacheConfig from cache_dit import BlockAdapter, DBCacheConfig
from cache_dit.caching.cache_adapters.cache_adapter import CachedAdapter from cache_dit.caching.cache_adapters.cache_adapter import CachedAdapter
from vllm.logger import init_logger from vllm.logger import init_logger
@@ -18,6 +21,9 @@ from vllm_omni.diffusion.cache.cachedit.config import (
) )
from vllm_omni.diffusion.data import DiffusionCacheConfig from vllm_omni.diffusion.data import DiffusionCacheConfig
if TYPE_CHECKING:
from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery
logger = init_logger(__name__) logger = init_logger(__name__)
RefreshCacheContextFunc: TypeAlias = Callable[[Any, int, bool], None] RefreshCacheContextFunc: TypeAlias = Callable[[Any, int, bool], None]
@@ -27,14 +33,18 @@ CacheDiTEnabler: TypeAlias = Callable[[Any, DiffusionCacheConfig], RefreshCacheC
CUSTOM_DIT_ENABLERS: dict[str, CacheDiTEnabler] = {} CUSTOM_DIT_ENABLERS: dict[str, CacheDiTEnabler] = {}
def cache_summary(pipeline: Any, details: bool = True) -> None: def _dit_module_names(pipeline: SupportsComponentDiscovery) -> tuple[str, ...]:
"""Return the pipeline DiT attributes that are present at runtime."""
names = getattr(pipeline, "_dit_modules", None)
if not isinstance(names, (list, tuple)):
names = ("transformer",)
return tuple(name for name in names if isinstance(name, str) and getattr(pipeline, name, None) is not None)
def cache_summary(pipeline: SupportsComponentDiscovery, details: bool = True) -> None:
"""Log Cache-DiT statistics for every transformer on the pipeline.""" """Log Cache-DiT statistics for every transformer on the pipeline."""
transformers = [ transformers = [getattr(pipeline, name) for name in _dit_module_names(pipeline)]
transformer
for attribute in ("transformer", "transformer_2")
if (transformer := getattr(pipeline, attribute, None)) is not None
]
for transformer in transformers: for transformer in transformers:
cache_dit.summary(transformer, details=details) cache_dit.summary(transformer, details=details)
@@ -42,19 +52,34 @@ def cache_summary(pipeline: Any, details: bool = True) -> None:
logger.warning("CacheDiT summary failed; this pipeline has no defined transformer attribute") logger.warning("CacheDiT summary failed; this pipeline has no defined transformer attribute")
def _default_get_pipeline_transformer(pipeline: Any) -> Any: def _default_get_pipeline_transformer(
return pipeline.transformer pipeline: SupportsComponentDiscovery,
) -> nn.Module:
return cast(nn.Module, getattr(pipeline, "transformer"))
def _make_pipeline_transformer_getter(
name: str,
) -> Callable[[SupportsComponentDiscovery], nn.Module]:
def get_pipeline_transformer(
pipeline: SupportsComponentDiscovery,
) -> nn.Module:
return cast(nn.Module, getattr(pipeline, name))
return get_pipeline_transformer
def _build_cache_context_refresh( def _build_cache_context_refresh(
cache_config: DiffusionCacheConfig, cache_config: DiffusionCacheConfig,
get_pipeline_transformer: Callable[[Any], Any] = _default_get_pipeline_transformer, get_pipeline_transformer: Callable[[SupportsComponentDiscovery], nn.Module] = _default_get_pipeline_transformer,
) -> RefreshCacheContextFunc: ) -> RefreshCacheContextFunc:
"""Build the cache context refresh callback for one transformer.""" """Build the cache context refresh callback for one transformer."""
projected_config = CacheDiTConfig.from_diffusion_config(cache_config) projected_config = CacheDiTConfig.from_diffusion_config(cache_config)
def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: def refresh_cache_context(
pipeline: SupportsComponentDiscovery, num_inference_steps: int, verbose: bool = True
) -> None:
transformer = get_pipeline_transformer(pipeline) transformer = get_pipeline_transformer(pipeline)
# Cache-DiT has no predefined SCM mask for these small step counts. # Cache-DiT has no predefined SCM mask for these small step counts.
@@ -80,10 +105,11 @@ def _build_cache_context_refresh(
def enable_cache_for_dit( def enable_cache_for_dit(
pipeline: Any, pipeline: SupportsComponentDiscovery,
cache_config: DiffusionCacheConfig, cache_config: DiffusionCacheConfig,
block_adapter: BlockAdapter | None = None, block_adapter: BlockAdapter | None = None,
adapter_cls: type[CachedAdapter] | None = None, adapter_cls: type[CachedAdapter] | None = None,
get_pipeline_transformer: Callable[[SupportsComponentDiscovery], nn.Module] = _default_get_pipeline_transformer,
) -> RefreshCacheContextFunc: ) -> RefreshCacheContextFunc:
"""Enable Cache-DiT for a standard single-transformer DiT pipeline.""" """Enable Cache-DiT for a standard single-transformer DiT pipeline."""
@@ -98,7 +124,7 @@ def enable_cache_for_dit(
db_cache_config.max_warmup_steps, db_cache_config.max_warmup_steps,
) )
transformer = _default_get_pipeline_transformer(pipeline) transformer = get_pipeline_transformer(pipeline)
cache_target = transformer if block_adapter is None else block_adapter cache_target = transformer if block_adapter is None else block_adapter
if adapter_cls is not None: if adapter_cls is not None:
adapter_cls.apply( adapter_cls.apply(
@@ -128,13 +154,16 @@ def enable_cache_for_dit(
calibrator_config=calibrator_config, calibrator_config=calibrator_config,
) )
return _build_cache_context_refresh(cache_config) return _build_cache_context_refresh(cache_config, get_pipeline_transformer)
def _maybe_build_block_adapter(pipeline: Any) -> BlockAdapter | None: def _maybe_build_block_adapter(
pipeline: SupportsComponentDiscovery,
get_pipeline_transformer: Callable[[SupportsComponentDiscovery], nn.Module] = _default_get_pipeline_transformer,
) -> BlockAdapter | None:
"""Build the model-declared block adapter, when one is configured.""" """Build the model-declared block adapter, when one is configured."""
transformer = _default_get_pipeline_transformer(pipeline) transformer = get_pipeline_transformer(pipeline)
adapter_config: CacheDiTAdapterConfig | None = getattr(transformer, "_cache_dit_adapter_config", None) adapter_config: CacheDiTAdapterConfig | None = getattr(transformer, "_cache_dit_adapter_config", None)
if adapter_config is None: if adapter_config is None:
logger.info( logger.info(
@@ -160,10 +189,13 @@ def _maybe_build_block_adapter(pipeline: Any) -> BlockAdapter | None:
) )
def _maybe_get_cached_adapter_cls(pipeline: Any) -> type[CachedAdapter] | None: def _maybe_get_cached_adapter_cls(
pipeline: SupportsComponentDiscovery,
get_pipeline_transformer: Callable[[SupportsComponentDiscovery], nn.Module] = _default_get_pipeline_transformer,
) -> type[CachedAdapter] | None:
"""Return the custom cached adapter declared by the transformer.""" """Return the custom cached adapter declared by the transformer."""
transformer = _default_get_pipeline_transformer(pipeline) transformer = get_pipeline_transformer(pipeline)
adapter_config: CacheDiTAdapterConfig | None = getattr(transformer, "_cache_dit_adapter_config", None) adapter_config: CacheDiTAdapterConfig | None = getattr(transformer, "_cache_dit_adapter_config", None)
return None if adapter_config is None else adapter_config.cached_adapter_cls return None if adapter_config is None else adapter_config.cached_adapter_cls
@@ -180,29 +212,37 @@ class CacheDiTBackend(CacheBackend):
config = cache_config config = cache_config
super().__init__(config) super().__init__(config)
self._refresh_func: RefreshCacheContextFunc | None = None self._refresh_funcs: list[RefreshCacheContextFunc] = []
def enable(self, pipeline: Any) -> None: def enable(self, pipeline: SupportsComponentDiscovery) -> None:
pipeline_name = type(pipeline).__name__ pipeline_name = type(pipeline).__name__
custom_enabler = CUSTOM_DIT_ENABLERS.get(pipeline_name) custom_enabler = CUSTOM_DIT_ENABLERS.get(pipeline_name)
if custom_enabler is not None: if custom_enabler is not None:
logger.info("Using custom cache-dit enabler for model: %s", pipeline_name) logger.info("Using custom cache-dit enabler for model: %s", pipeline_name)
self._refresh_func = custom_enabler(pipeline, self.config) self._refresh_funcs = [custom_enabler(pipeline, self.config)]
else: else:
block_adapter = _maybe_build_block_adapter(pipeline) self._refresh_funcs = []
adapter_cls = _maybe_get_cached_adapter_cls(pipeline) for name in _dit_module_names(pipeline):
self._refresh_func = enable_cache_for_dit( get_transformer = _make_pipeline_transformer_getter(name)
block_adapter = _maybe_build_block_adapter(pipeline, get_transformer)
adapter_cls = _maybe_get_cached_adapter_cls(pipeline, get_transformer)
self._refresh_funcs.append(
enable_cache_for_dit(
pipeline, pipeline,
self.config, self.config,
block_adapter, block_adapter,
adapter_cls, adapter_cls,
get_transformer,
) )
)
if not self._refresh_funcs:
raise ValueError(f"Pipeline {pipeline_name} has no declared DiT modules for Cache-DiT")
self.enabled = True self.enabled = True
logger.info("Cache-dit enabled successfully on %s", pipeline_name) logger.info("Cache-dit enabled successfully on %s", pipeline_name)
def refresh(self, pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: def refresh(self, pipeline: SupportsComponentDiscovery, num_inference_steps: int, verbose: bool = True) -> None:
if not self.enabled or self._refresh_func is None: if not self.enabled or not self._refresh_funcs:
logger.warning("Cache-dit is not enabled. Cannot refresh cache context.") logger.warning("Cache-dit is not enabled. Cannot refresh cache context.")
return return
@@ -211,7 +251,8 @@ class CacheDiTBackend(CacheBackend):
"Refreshing cache context for transformer with num_inference_steps: %s", "Refreshing cache context for transformer with num_inference_steps: %s",
num_inference_steps, num_inference_steps,
) )
self._refresh_func(pipeline, num_inference_steps, verbose) for refresh_func in self._refresh_funcs:
refresh_func(pipeline, num_inference_steps, verbose)
__all__ = [ __all__ = [
+69 -13
View File
@@ -541,16 +541,15 @@ def resolve_model_class_name(model: str | None, diffusion_load_format: str = "de
""" """
from vllm.transformers_utils.config import get_hf_file_to_dict from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm_omni.diffusion.utils.hf_utils import get_diffusion_model_index
if not model: if not model:
return None return None
is_lance_subfolder = os.path.basename(str(model).rstrip("/")) in {"Lance_3B", "Lance_3B_Video"} is_lance_subfolder = os.path.basename(str(model).rstrip("/")) in {"Lance_3B", "Lance_3B_Video"}
# Diffusers models: read _class_name from model_index.json. # Diffusers models: read _class_name from the pipeline index.
try: model_index = get_diffusion_model_index(model)
model_index = get_hf_file_to_dict("model_index.json", model)
except Exception:
model_index = None
if model_index is not None: if model_index is not None:
return model_index.get("_class_name") return model_index.get("_class_name")
if diffusion_load_format == "diffusers": if diffusion_load_format == "diffusers":
@@ -603,6 +602,10 @@ class OmniDiffusionConfig:
model_class_name: str | None = None model_class_name: str | None = None
# Optional model-defined startup task. Pipelines may use this to select
# task-specific components or weights before serving requests.
task_type: str | None = None
dtype: torch.dtype = torch.bfloat16 dtype: torch.dtype = torch.bfloat16
model_config: dict[str, Any] = field(default_factory=dict) model_config: dict[str, Any] = field(default_factory=dict)
@@ -1143,18 +1146,23 @@ class OmniDiffusionConfig:
def enrich_config(self) -> None: def enrich_config(self) -> None:
"""Load model metadata from HuggingFace and populate config fields. """Load model metadata from HuggingFace and populate config fields.
Diffusers-style models expose ``model_index.json`` with ``_class_name``. Diffusers-style models expose a pipeline index with ``_class_name``.
Non-diffusers models (e.g. Bagel, NextStep) only have ``config.json``, Non-diffusers models (e.g. Bagel, NextStep) only have ``config.json``,
so we fall back to reading that and mapping model_type manually. so we fall back to reading that and mapping model_type manually.
""" """
from vllm.transformers_utils.config import get_hf_file_to_dict from vllm.transformers_utils.config import get_hf_file_to_dict
from vllm_omni.diffusion.utils.hf_utils import get_diffusion_model_index
# Default model_class_name for diffusers adapter # Default model_class_name for diffusers adapter
if self.model_class_name is None and self.diffusion_load_format == "diffusers": if self.model_class_name is None and self.diffusion_load_format == "diffusers":
self.model_class_name = "DiffusersAdapterPipeline" self.model_class_name = "DiffusersAdapterPipeline"
try: try:
config_dict = get_hf_file_to_dict("model_index.json", self.model) config_dict = get_diffusion_model_index(
self.model,
revision=self.revision,
)
if config_dict is not None: if config_dict is not None:
if self.model_class_name is None: if self.model_class_name is None:
self.model_class_name = config_dict.get("_class_name", None) self.model_class_name = config_dict.get("_class_name", None)
@@ -1169,7 +1177,7 @@ class OmniDiffusionConfig:
self.diffusers_pipeline_cls = getattr(diffusers, diffusers_pipeline_cls_name) self.diffusers_pipeline_cls = getattr(diffusers, diffusers_pipeline_cls_name)
except (KeyError, AttributeError) as exc: except (KeyError, AttributeError) as exc:
logger.warning( logger.warning(
"Could not find valid _class_name for diffusers pipeline in model_index.json: %s. " "Could not find a valid _class_name in the Diffusers pipeline index: %s. "
"Without the underlying pipeline class the dummy run may omit required inputs.", "Without the underlying pipeline class the dummy run may omit required inputs.",
exc, exc,
) )
@@ -1182,16 +1190,16 @@ class OmniDiffusionConfig:
else: else:
self.set_tf_model_config(TransformerConfig()) self.set_tf_model_config(TransformerConfig())
else: else:
raise FileNotFoundError("model_index.json not found") raise FileNotFoundError("Diffusers pipeline index not found")
except (AttributeError, OSError, ValueError, FileNotFoundError): except (AttributeError, OSError, ValueError, FileNotFoundError):
# Skip transformer config loading for diffusers adapter # Skip transformer config loading for diffusers adapter
# (non-DiT models don't have a separate transformer folder/config) # (non-DiT models don't have a separate transformer folder/config)
if self.diffusion_load_format == "diffusers": if self.diffusion_load_format == "diffusers":
self.set_tf_model_config(TransformerConfig()) self.set_tf_model_config(TransformerConfig())
logger.warning( logger.warning(
"Could not find valid model_index.json per diffusers format. " "Could not find a valid pipeline index per Diffusers format. "
"This model is likely unsupported by the diffusers backend. " "This model is likely unsupported by the diffusers backend. "
"Also, without knowing the underlying diffusers pipeline class from model_index.json, " "Also, without knowing the underlying pipeline class from its index, "
"the dummy run will input only text prompt, which may cause errors for pipelines " "the dummy run will input only text prompt, which may cause errors for pipelines "
"that require additional inputs." "that require additional inputs."
) )
@@ -1207,7 +1215,7 @@ class OmniDiffusionConfig:
self.set_tf_model_config(TransformerConfig()) self.set_tf_model_config(TransformerConfig())
self.update_multimodal_support() self.update_multimodal_support()
return return
raise ValueError(f"Could not find config.json or model_index.json for model {self.model}") raise ValueError(f"Could not find config.json or a Diffusers pipeline index for {self.model}")
self.set_tf_model_config(TransformerConfig.from_dict(cfg)) self.set_tf_model_config(TransformerConfig.from_dict(cfg))
model_type = cfg.get("model_type") model_type = cfg.get("model_type")
@@ -1434,6 +1442,10 @@ class SkipSoftmaxSpec:
if self.target_sparsity is not None and self.threshold is not None: if self.target_sparsity is not None and self.threshold is not None:
raise ValueError("skip_softmax: set either target_sparsity or threshold, not both.") raise ValueError("skip_softmax: set either target_sparsity or threshold, not both.")
@property
def enabled(self) -> bool:
return self.threshold is not None or self.target_sparsity is not None
@dataclass @dataclass
class AttnQuantSpec: class AttnQuantSpec:
@@ -1461,6 +1473,33 @@ class AttnQuantSpec:
return self.dtype_qk is not None or self.dtype_vo is not None return self.dtype_qk is not None or self.dtype_vo is not None
# Backends that select key blocks instead of attending densely, and so accept
# a ``block_sparse`` spec. Each maps the same knobs onto its own kernel.
BLOCK_SPARSE_BACKENDS = frozenset({"RAINFUSION_ATTN"})
@dataclass
class BlockSparseSpec:
"""User-facing controls shared by block-sparse attention backends.
``sparsity`` is the nominal fraction of key blocks dropped per query block;
``start_step`` keeps the first N denoise steps dense and ``skip_layers`` (an
index selector such as "0-3,38") exempts individual DiT blocks. Those two are
the accuracy knobs to trade back quality at a fixed ``sparsity``.
"""
sparsity: float = 0.8
start_step: int = 0
skip_layers: str | list[int] | None = None
skip_layer_indices: set[int] | None = field(default=None, repr=False)
def __post_init__(self) -> None:
self.sparsity = _in_range(self.sparsity, "block_sparse.sparsity", 0.0, 1.0) or 0.0
if self.start_step < 0:
raise ValueError(f"block_sparse.start_step must be >= 0; got {self.start_step!r}.")
self.skip_layer_indices = parse_kv_cache_skip_selector(self.skip_layers)
@dataclass @dataclass
class AttentionSpec: class AttentionSpec:
"""Specifies a backend and its typed backend-specific config for one attention role.""" """Specifies a backend and its typed backend-specific config for one attention role."""
@@ -1468,6 +1507,7 @@ class AttentionSpec:
backend: str backend: str
skip_softmax: SkipSoftmaxSpec | None = None skip_softmax: SkipSoftmaxSpec | None = None
quant: AttnQuantSpec | None = None quant: AttnQuantSpec | None = None
block_sparse: BlockSparseSpec | None = None
skip_calibration: dict | None = field(default=None, repr=False) skip_calibration: dict | None = field(default=None, repr=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -1475,6 +1515,7 @@ class AttentionSpec:
raise TypeError(f"Expected str for AttentionSpec.backend, got {type(self.backend)!r}") raise TypeError(f"Expected str for AttentionSpec.backend, got {type(self.backend)!r}")
self.skip_softmax = self._coerce(self.skip_softmax, SkipSoftmaxSpec, "skip_softmax") self.skip_softmax = self._coerce(self.skip_softmax, SkipSoftmaxSpec, "skip_softmax")
self.quant = self._coerce(self.quant, AttnQuantSpec, "quant") self.quant = self._coerce(self.quant, AttnQuantSpec, "quant")
self.block_sparse = self._coerce(self.block_sparse, BlockSparseSpec, "block_sparse")
if self.skip_softmax is not None and self.backend.upper() != "TRTLLM_ATTN": if self.skip_softmax is not None and self.backend.upper() != "TRTLLM_ATTN":
raise ValueError( raise ValueError(
f"skip_softmax is only supported by the TRTLLM_ATTN backend, but backend={self.backend!r}. " f"skip_softmax is only supported by the TRTLLM_ATTN backend, but backend={self.backend!r}. "
@@ -1485,6 +1526,15 @@ class AttentionSpec:
f"quant is only supported by the TRTLLM_ATTN and FLASHINFER_ATTN backends, but " f"quant is only supported by the TRTLLM_ATTN and FLASHINFER_ATTN backends, but "
f"backend={self.backend!r}. Remove quant or set a supported backend." f"backend={self.backend!r}. Remove quant or set a supported backend."
) )
if self.backend.upper() in BLOCK_SPARSE_BACKENDS:
# Selecting the backend is the opt-in; without an explicit block the
# defaults apply rather than silently running dense.
self.block_sparse = self.block_sparse or BlockSparseSpec()
elif self.block_sparse is not None:
raise ValueError(
f"block_sparse is only supported by the {sorted(BLOCK_SPARSE_BACKENDS)} backends, but "
f"backend={self.backend!r}. Remove block_sparse or set a supported backend."
)
@staticmethod @staticmethod
def _coerce(value: Any, cls: type, field_name: str) -> Any: def _coerce(value: Any, cls: type, field_name: str) -> Any:
@@ -1517,6 +1567,12 @@ class AttentionSpec:
if q.flashinfer_backend is not None: if q.flashinfer_backend is not None:
quant_kw["flashinfer_backend"] = q.flashinfer_backend quant_kw["flashinfer_backend"] = q.flashinfer_backend
kw["quant"] = quant_kw kw["quant"] = quant_kw
if self.block_sparse is not None:
bs = self.block_sparse
kw["sparsity"] = bs.sparsity
kw["start_step"] = bs.start_step
if bs.skip_layer_indices:
kw["skip_layers"] = sorted(bs.skip_layer_indices)
return kw or None return kw or None
@@ -1586,7 +1642,7 @@ class AttentionConfig:
normalized[role] = node normalized[role] = node
return return
spec_keys = {"backend", "skip_softmax", "quant"} spec_keys = {"backend", "skip_softmax", "quant", "block_sparse"}
node_dict = dict(node) node_dict = dict(node)
node_keys = set(node_dict) node_keys = set(node_dict)
if node_keys & spec_keys: if node_keys & spec_keys:
+7 -1
View File
@@ -221,9 +221,15 @@ class DenoiseProgressMixin:
step_idx: int | None, step_idx: int | None,
timestep=None, timestep=None,
scheduler=None, scheduler=None,
normalized_timestep: float | None = None,
) -> None: ) -> None:
set_forward_context_denoise_step_idx(step_idx) set_forward_context_denoise_step_idx(step_idx)
if timestep is None or _forward_context is None: if _forward_context is None:
return
if normalized_timestep is not None:
_forward_context.denoise_timestep = float(normalized_timestep)
return
if timestep is None:
return return
scheduler = scheduler if scheduler is not None else getattr(self, "scheduler", None) scheduler = scheduler if scheduler is not None else getattr(self, "scheduler", None)
ntt = getattr(getattr(scheduler, "config", None), "num_train_timesteps", None) ntt = getattr(getattr(scheduler, "config", None), "num_train_timesteps", None)
+5 -3
View File
@@ -366,8 +366,8 @@ class DiffusionLoRAManager:
fully_sharded_loras=False, fully_sharded_loras=False,
) )
# Default denoising components plus any a pipeline opts into via # Default denoising components, declared DiT components, and any a
# ``_lora_components`` (opt-in; other pipelines unchanged). # pipeline opts into via ``_lora_components``.
# #
# NOTE: SDXL-style pipelines expose the denoiser as ``unet``. # NOTE: SDXL-style pipelines expose the denoiser as ``unet``.
# Without scanning this component, adapters can load/activate while # Without scanning this component, adapters can load/activate while
@@ -379,8 +379,10 @@ class DiffusionLoRAManager:
"bagel", "bagel",
"unet", "unet",
) )
declared_components = tuple(getattr(self.pipeline, "_dit_modules", ()) or ())
extra_components = tuple(getattr(self.pipeline, "_lora_components", ()) or ()) extra_components = tuple(getattr(self.pipeline, "_lora_components", ()) or ())
for component_name in (*default_components, *extra_components): component_names = dict.fromkeys((*default_components, *declared_components, *extra_components))
for component_name in component_names:
if not hasattr(self.pipeline, component_name): if not hasattr(self.pipeline, component_name):
continue continue
component = getattr(self.pipeline, component_name) component = getattr(self.pipeline, component_name)
@@ -393,6 +393,13 @@ class DiffusersPipelineLoader:
"DLO+AllGather active but model does not support mmap: loading weights via regular loader." "DLO+AllGather active but model does not support mmap: loading weights via regular loader."
) )
logger.debug("Loading weights on %s ...", load_device) logger.debug("Loading weights on %s ...", load_device)
if offload_after_quant:
marked = self._request_offload_after_quant(model)
if marked:
logger.info(
"Online quantization will return each of %d layers to CPU as it is quantized",
marked,
)
if load_format == "diffusers": if load_format == "diffusers":
cast(DiffusersAdapterPipeline, model).load_weights() cast(DiffusersAdapterPipeline, model).load_weights()
else: else:
@@ -406,6 +413,29 @@ class DiffusersPipelineLoader:
self._apply_skip_softmax_calibration(model) self._apply_skip_softmax_calibration(model)
return model.eval() return model.eval()
@staticmethod
def _request_offload_after_quant(model: nn.Module) -> int:
"""Ask online-quant layers to return to host memory once quantized.
The weights only visit the accelerator so the quant kernels can run on
them; ``load_model`` sends the model back to the host afterwards either
way. Without this the whole transformer accumulates on device until that
final move, which for MiniMax H3 is a ~43 GiB peak that no longer fits
beside a resident TP-sharded text encoder — even though layer-wise
offload means none of it is supposed to be resident at inference time.
Only quant methods that advertise ``supports_offload_after_quant`` are
asked, since the implementation has to know when a layer is finished.
Deferring materialization to the ``meta`` device does not imply that.
"""
marked = 0
for module in model.modules():
quant_method = getattr(module, "quant_method", None)
if getattr(quant_method, "supports_offload_after_quant", False):
quant_method.enable_offload_after_quant()
marked += 1
return marked
@staticmethod @staticmethod
def _has_online_quant(model: nn.Module) -> bool: def _has_online_quant(model: nn.Module) -> bool:
"""Whether any layer uses an online-quant method that defers weight """Whether any layer uses an online-quant method that defers weight
@@ -460,6 +490,13 @@ class DiffusersPipelineLoader:
if quant_method is None or not isinstance(quant_method, QuantizeMethodBase): if quant_method is None or not isinstance(quant_method, QuantizeMethodBase):
continue continue
# Layers finished during loading would only be staged onto the target
# device for a process call that immediately returns. That round trip
# is wasted work in general, and undoes the point of the offload for
# layers that already went back to the host.
if getattr(module, "_already_called_process_weights_after_loading", False):
continue
if has_online_quant: if has_online_quant:
# Online quant may leave straggler params on the ``meta`` device. # Online quant may leave straggler params on the ``meta`` device.
# Move only real (non-meta) params onto the target device for # Move only real (non-meta) params onto the target device for
+10
View File
@@ -41,6 +41,16 @@ _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = {
supports_multimodal_inputs=True, supports_multimodal_inputs=True,
max_multimodal_image_inputs=9, max_multimodal_image_inputs=9,
supports_mixed_reference_inputs=True, supports_mixed_reference_inputs=True,
# H3 represents alignment padding as a second packed sequence. The
# packed TRTLLM backend consumes cu_seqlens and isolates that padding.
attention_mask_free=True,
),
# The modular alias is served by MiniMaxH3Pipeline and has the same
# Ref2VA request contract. Keep admission limits in sync with it.
"MiniMaxH3ModularPipeline": DiffusionModelMetadata(
supports_multimodal_inputs=True,
max_multimodal_image_inputs=9,
supports_mixed_reference_inputs=True,
), ),
"WanPipeline": DiffusionModelMetadata(attention_mask_free=True), "WanPipeline": DiffusionModelMetadata(attention_mask_free=True),
"WanImageToVideoPipeline": DiffusionModelMetadata(attention_mask_free=True), "WanImageToVideoPipeline": DiffusionModelMetadata(attention_mask_free=True),
@@ -14,6 +14,9 @@ from typing import Any
import torch import torch
from vllm_omni.diffusion.attention.backends.abstract import VideoTokenLayout
from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx
from .scheduling_minimax_h3_euler_ancestral import ( from .scheduling_minimax_h3_euler_ancestral import (
minimax_h3_euler_eta0_step, minimax_h3_euler_eta0_step,
minimax_h3_rf_v_to_x0, minimax_h3_rf_v_to_x0,
@@ -87,6 +90,13 @@ class MiniMaxH3DenoiseBranch:
"max_seqlen_q": text_len, "max_seqlen_q": text_len,
}, },
} }
# Where the video segment sits in the packed sequence. Resolved to plain
# ints here so the attention layers never sync on it per step.
grid = packed["latent_grid"].tolist()
self.static_kwargs["video_token_layout"] = VideoTokenLayout(
prefix_len=int(packed["video_row_start"]),
latent_grid=(int(grid[0]), int(grid[1]), int(grid[2])),
)
def forward_kwargs( def forward_kwargs(
self, self,
@@ -139,7 +149,8 @@ def minimax_h3_denoise_loop(
device: torch.device, device: torch.device,
imgvid_cond_noise_aug_for_inference: float = MINIMAX_H3_IMGVID_COND_TIMESTEP, imgvid_cond_noise_aug_for_inference: float = MINIMAX_H3_IMGVID_COND_TIMESTEP,
audio_cond_noise_aug_for_inference: float = MINIMAX_H3_AUDIO_REF_COND_TIMESTEP, audio_cond_noise_aug_for_inference: float = MINIMAX_H3_AUDIO_REF_COND_TIMESTEP,
on_step: Callable[[int, torch.Tensor, torch.Tensor], None] | None = None, on_step_end: Callable[[int, torch.Tensor, torch.Tensor], None] | None = None,
on_step_start: Callable[[int, float, float], None] | None = None,
step_profiler: Callable[[int], AbstractContextManager] | None = None, step_profiler: Callable[[int], AbstractContextManager] | None = None,
) -> tuple[torch.Tensor, torch.Tensor]: ) -> tuple[torch.Tensor, torch.Tensor]:
"""Run the full denoise loop; returns final (video_rows, audio_rows). """Run the full denoise loop; returns final (video_rows, audio_rows).
@@ -191,8 +202,14 @@ def minimax_h3_denoise_loop(
for step in range(num_steps): for step in range(num_steps):
step_cm = step_profiler(step) if step_profiler is not None else nullcontext() step_cm = step_profiler(step) if step_profiler is not None else nullcontext()
with step_cm: with step_cm:
# Publish the step index so step-gated attention features (e.g. the
# dense warmup of RAINFUSION_ATTN) can see where we are.
set_forward_context_denoise_step_idx(step)
s_v, s_v_next = sigmas_video[step], sigmas_video[step + 1] s_v, s_v_next = sigmas_video[step], sigmas_video[step + 1]
s_a, s_a_next = sigmas_audio[step], sigmas_audio[step + 1] s_a, s_a_next = sigmas_audio[step], sigmas_audio[step + 1]
if on_step_start is not None:
# Attention gates use the scheduler-style descending timestep.
on_step_start(step, s_v, s_a)
t_v, t_a = 1.0 - s_v, 1.0 - s_a t_v, t_a = 1.0 - s_v, 1.0 - s_a
imgvid_cond_t = max(t_v, float(imgvid_cond_noise_aug_for_inference)) imgvid_cond_t = max(t_v, float(imgvid_cond_noise_aug_for_inference))
audio_ref_cond_t = max(t_a, float(audio_cond_noise_aug_for_inference)) audio_ref_cond_t = max(t_a, float(audio_cond_noise_aug_for_inference))
@@ -233,9 +250,10 @@ def minimax_h3_denoise_loop(
audio_rows[audio_update] = new_audio audio_rows[audio_update] = new_audio
if audio_anchor is not None: if audio_anchor is not None:
audio_rows[~audio_update] = audio_anchor # per-step audio ref reset audio_rows[~audio_update] = audio_anchor # per-step audio ref reset
if on_step is not None: if on_step_end is not None:
on_step(step, video_rows, audio_rows) on_step_end(step, video_rows, audio_rows)
set_forward_context_denoise_step_idx(None)
return video_rows, audio_rows return video_rows, audio_rows
@@ -26,7 +26,7 @@ from vllm.model_executor.layers.linear import (
) )
from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm.model_executor.model_loader.weight_utils import default_weight_loader
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, VideoTokenLayout
from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig
from vllm_omni.diffusion.distributed.sp_plan import ( from vllm_omni.diffusion.distributed.sp_plan import (
@@ -134,6 +134,7 @@ _FORWARD_SUPPORTED_KWARGS = frozenset(
"img_pos_for_infer_output_info", "img_pos_for_infer_output_info",
"packed_seq_params", "packed_seq_params",
"refiner_packed_seq_params", "refiner_packed_seq_params",
"video_token_layout",
} }
) )
@@ -310,6 +311,8 @@ class MiniMaxH3Attention(nn.Module):
quant_config: QuantizationConfig | None, quant_config: QuantizationConfig | None,
*, *,
prefix: str, prefix: str,
role: str = "self",
role_category: str | None = None,
skip_sequence_parallel: bool = False, skip_sequence_parallel: bool = False,
) -> None: ) -> None:
super().__init__() super().__init__()
@@ -348,7 +351,12 @@ class MiniMaxH3Attention(nn.Module):
head_size=self.head_dim, head_size=self.head_dim,
softmax_scale=self.softmax_scale, softmax_scale=self.softmax_scale,
causal=False, causal=False,
# Packed rows reach the impl as [B, S, N, D].
qkv_layout="BSND",
role=role,
role_category=role_category,
skip_sequence_parallel=skip_sequence_parallel, skip_sequence_parallel=skip_sequence_parallel,
prefix=prefix,
) )
def _apply_rope(self, x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor: def _apply_rope(self, x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor:
@@ -374,6 +382,7 @@ class MiniMaxH3Attention(nn.Module):
cu_seqlens: torch.Tensor, cu_seqlens: torch.Tensor,
max_seqlen: int, max_seqlen: int,
packed_total: int, packed_total: int,
video_layout: VideoTokenLayout | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
"""Run packed attention as a small eager island. """Run packed attention as a small eager island.
@@ -407,6 +416,7 @@ class MiniMaxH3Attention(nn.Module):
"max_seqlen_k": max_seqlen, "max_seqlen_k": max_seqlen,
"valid_kv_length": used, "valid_kv_length": used,
}, },
video_layout=video_layout,
) )
return self.attention( return self.attention(
q.unsqueeze(0), q.unsqueeze(0),
@@ -424,6 +434,7 @@ class MiniMaxH3Attention(nn.Module):
max_seqlen: int, max_seqlen: int,
packed_total: int | None = None, packed_total: int | None = None,
sp_seq_lens: list[int] | None = None, sp_seq_lens: list[int] | None = None,
video_layout: VideoTokenLayout | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
"""x: [T, hidden] packed thd rows -> [T, hidden]. """x: [T, hidden] packed thd rows -> [T, hidden].
@@ -463,6 +474,7 @@ class MiniMaxH3Attention(nn.Module):
# backend receives the global sequence after all-to-all, so carry # backend receives the global sequence after all-to-all, so carry
# its Python length explicitly instead of inferring it from q. # its Python length explicitly instead of inferring it from q.
packed_total=packed_total if packed_total is not None else q.shape[0], packed_total=packed_total if packed_total is not None else q.shape[0],
video_layout=video_layout,
) )
out = out.reshape(total, self.num_heads * self.head_dim) out = out.reshape(total, self.num_heads * self.head_dim)
out, _ = self.out_proj(out) out, _ = self.out_proj(out)
@@ -573,6 +585,8 @@ class MiniMaxH3TokenRefinerBlock(nn.Module):
arch, arch,
quant_config, quant_config,
prefix=f"{prefix}.attn", prefix=f"{prefix}.attn",
role="minimax_h3.token_refiner",
role_category="self",
skip_sequence_parallel=True, skip_sequence_parallel=True,
) )
self.mlp = MiniMaxH3MLP( self.mlp = MiniMaxH3MLP(
@@ -642,6 +656,8 @@ class MiniMaxH3DiTBlock(nn.Module):
super().__init__() super().__init__()
self.norm1 = _norm(arch.hidden_size, eps=arch.norm_eps) self.norm1 = _norm(arch.hidden_size, eps=arch.norm_eps)
self.norm2 = _norm(arch.hidden_size, eps=arch.norm_eps) self.norm2 = _norm(arch.hidden_size, eps=arch.norm_eps)
# The prefix also carries the block index that block-sparse attention
# backends match against their skip_layers selector.
self.attn = MiniMaxH3Attention( self.attn = MiniMaxH3Attention(
arch, arch,
quant_config, quant_config,
@@ -672,6 +688,7 @@ class MiniMaxH3DiTBlock(nn.Module):
max_seqlen: int, max_seqlen: int,
packed_total: int, packed_total: int,
sp_seq_lens: list[int] | None = None, sp_seq_lens: list[int] | None = None,
video_layout: VideoTokenLayout | None = None,
) -> torch.Tensor: ) -> torch.Tensor:
"""x: [T, H]; t_emb: [M, t_dim]; combined_indices: [T] """x: [T, H]; t_emb: [M, t_dim]; combined_indices: [T]
(= inverse_indices * modality_num + token_tags.clamp(min=0)). (= inverse_indices * modality_num + token_tags.clamp(min=0)).
@@ -699,6 +716,7 @@ class MiniMaxH3DiTBlock(nn.Module):
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
packed_total=packed_total, packed_total=packed_total,
sp_seq_lens=sp_seq_lens, sp_seq_lens=sp_seq_lens,
video_layout=video_layout,
) )
x = _modulate_gate(residual, gate_msa, h, combined_indices, dtype=_BF16_DTYPE) x = _modulate_gate(residual, gate_msa, h, combined_indices, dtype=_BF16_DTYPE)
@@ -831,6 +849,10 @@ class MiniMaxH3DiTModel(nn.Module):
}, },
"sp_gather": SequenceParallelOutput(gather_dim=0, expected_dims=2), "sp_gather": SequenceParallelOutput(gather_dim=0, expected_dims=2),
} }
# The checkpoint already stores qkv and the MLP gate/up as single tensors
# (see the reordering in load_weights), so there are no unfused names for
# quantization or LoRA to map onto. Address the fused layers directly, e.g.
# ignored_layers=["blocks.0.attn.qkv_proj"].
packed_modules_mapping = {} packed_modules_mapping = {}
def _validate_tp_config(self, *, arch: MiniMaxH3DiTArchConfig, tp_size: int) -> None: def _validate_tp_config(self, *, arch: MiniMaxH3DiTArchConfig, tp_size: int) -> None:
@@ -1097,6 +1119,7 @@ class MiniMaxH3DiTModel(nn.Module):
refiner_psp = _required_kwarg(kwargs, "refiner_packed_seq_params") refiner_psp = _required_kwarg(kwargs, "refiner_packed_seq_params")
refiner_cu = self._psp_field(refiner_psp, "refiner_packed_seq_params", "cu_seqlens_q").to(torch.int32) refiner_cu = self._psp_field(refiner_psp, "refiner_packed_seq_params", "cu_seqlens_q").to(torch.int32)
refiner_max = int(self._psp_field(refiner_psp, "refiner_packed_seq_params", "max_seqlen_q")) refiner_max = int(self._psp_field(refiner_psp, "refiner_packed_seq_params", "max_seqlen_q"))
video_layout = kwargs.get("video_token_layout")
if x.dim() != 3 or x.shape[0] != 1: if x.dim() != 3 or x.shape[0] != 1:
raise ValueError(f"x must be [1, S, C], got {list(x.shape)}") raise ValueError(f"x must be [1, S, C], got {list(x.shape)}")
@@ -1145,6 +1168,7 @@ class MiniMaxH3DiTModel(nn.Module):
cu_seqlens=cu_seqlens, cu_seqlens=cu_seqlens,
max_seqlen=max_seqlen, max_seqlen=max_seqlen,
packed_total=seq_len, packed_total=seq_len,
video_layout=video_layout,
) )
hidden = self.sp_gather(hidden) hidden = self.sp_gather(hidden)
@@ -236,6 +236,9 @@ def minimax_h3_packed_sequence(
"token_tags": token_tags, "token_tags": token_tags,
"cu_seqlens": cu, "cu_seqlens": cu,
"document_id": doc, "document_id": doc,
# Extent of the video segment within the packed sequence.
"latent_grid": torch.tensor([latent_t, ph, pw], dtype=torch.int64),
"video_row_start": torch.tensor(video_sl.start, dtype=torch.int64),
} }
@@ -554,6 +557,9 @@ def minimax_h3_packed_sequence_ref2va_blocks(
"token_tags": token_tags, "token_tags": token_tags,
"cu_seqlens": cu, "cu_seqlens": cu,
"document_id": doc, "document_id": doc,
# Extent of the video segment within the packed sequence.
"latent_grid": torch.tensor([latent_t, ph, pw], dtype=torch.int64),
"video_row_start": torch.tensor(video_sl.start, dtype=torch.int64),
} }
@@ -9,6 +9,7 @@ import os
import tempfile import tempfile
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from itertools import groupby
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar from typing import Any, ClassVar
@@ -27,6 +28,7 @@ from vllm_omni.diffusion.distributed.parallel_state import (
init_world_group, init_world_group,
) )
from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.forward_context import DenoiseProgressMixin
from vllm_omni.diffusion.model_loader.diffusers_loader import ( from vllm_omni.diffusion.model_loader.diffusers_loader import (
DiffusersPipelineLoader, DiffusersPipelineLoader,
) )
@@ -43,6 +45,9 @@ from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import (
) )
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
from vllm_omni.errors import OmniClientError from vllm_omni.errors import OmniClientError
from vllm_omni.model_executor.model_loader.weight_utils import (
download_weights_from_hf_specific,
)
from vllm_omni.platforms import current_omni_platform from vllm_omni.platforms import current_omni_platform
from .condition_noise import ( from .condition_noise import (
@@ -106,6 +111,57 @@ MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES = 30 * 1024 * 1024
MINIMAX_H3_REFERENCE_IMAGE_FORMATS = frozenset({"jpeg", "png", "webp", "heic", "heif"}) MINIMAX_H3_REFERENCE_IMAGE_FORMATS = frozenset({"jpeg", "png", "webp", "heic", "heif"})
MINIMAX_H3_MIN_OUTPUT_SECONDS = 4.0 MINIMAX_H3_MIN_OUTPUT_SECONDS = 4.0
MINIMAX_H3_MAX_OUTPUT_SECONDS = 15.0 MINIMAX_H3_MAX_OUTPUT_SECONDS = 15.0
MINIMAX_H3_DOWNLOAD_PATTERNS = [
"FL2VA/**",
"Ref2VA/model_index.json",
"Ref2VA/transformer/**",
]
MINIMAX_H3_TASK_DOWNLOAD_PATTERNS = {
"fl2va": ["FL2VA/**"],
"ref2va": ["Ref2VA/**"],
}
def _minimax_h3_partition_for_task(
task_type: str | None,
model: str | None = None,
) -> str:
task = str(task_type or "auto").lower()
if task == "auto" and model is not None:
path = Path(model)
if path.is_dir() and path.name in {"FL2VA", "Ref2VA"} and (path / "model_index.json").is_file():
return path.name.lower()
if task in {"auto", "combined"}:
return "combined"
if task in {"t2va", "fl2va"}:
return "fl2va"
if task == "ref2va":
return "ref2va"
raise ValueError(f"MiniMax-H3 task_type must be one of auto, t2va, fl2va, or ref2va; got {task_type!r}")
def _resolve_minimax_h3_model_root(
model: str,
revision: str | None,
partition: str,
) -> Path:
path = Path(model)
if path.is_dir():
if path.name in {"FL2VA", "Ref2VA"} and (path / "model_index.json").is_file():
return path.parent
return path
allow_patterns = (
MINIMAX_H3_DOWNLOAD_PATTERNS if partition == "combined" else MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[partition]
)
return Path(
download_weights_from_hf_specific(
model_name_or_path=model,
cache_dir=None,
allow_patterns=allow_patterns,
revision=revision,
require_all=True,
)
)
def _resolve_component_quant_config(quant_config, component: str): def _resolve_component_quant_config(quant_config, component: str):
@@ -446,6 +502,7 @@ class _SingleRankEncoderGroup:
class MiniMaxH3Pipeline( class MiniMaxH3Pipeline(
nn.Module, nn.Module,
DenoiseProgressMixin,
ProgressBarMixin, ProgressBarMixin,
DiffusionPipelineProfilerMixin, DiffusionPipelineProfilerMixin,
SupportImageInput, SupportImageInput,
@@ -455,11 +512,7 @@ class MiniMaxH3Pipeline(
): ):
"""CFG-distilled joint video/audio generation for MiniMax H3.""" """CFG-distilled joint video/audio generation for MiniMax H3."""
_dit_modules: ClassVar[list[str]] = ["transformer"] _dit_modules: ClassVar[list[str]] = ["transformer", "transformers_ref"]
# TODO(offload): Reconcile this static plan with the modular H3 pipeline
# (#5720). Its combined mode has ``transformer`` and ``transformers_ref``;
# build the resident/staging plan from the selected task partition so a
# consumer-GPU single-task server never stages both DiTs.
_encoder_modules: ClassVar[list[str]] = ["text_encoder"] _encoder_modules: ClassVar[list[str]] = ["text_encoder"]
_vae_modules: ClassVar[list[str]] = ["video_vae", "audio_vae"] _vae_modules: ClassVar[list[str]] = ["video_vae", "audio_vae"]
_offload_plan: ClassVar[OffloadPlan] = OffloadPlan( _offload_plan: ClassVar[OffloadPlan] = OffloadPlan(
@@ -498,24 +551,64 @@ class MiniMaxH3Pipeline(
if int(self.parallel_config.cfg_parallel_size) != 1: if int(self.parallel_config.cfg_parallel_size) != 1:
raise ValueError("MiniMax-H3 is CFG-distilled and has no negative branch; cfg_parallel_size must be 1") raise ValueError("MiniMax-H3 is CFG-distilled and has no negative branch; cfg_parallel_size must be 1")
self.device = get_local_device() self.device = get_local_device()
model_path = str(od_config.model) self.partition = _minimax_h3_partition_for_task(
model_index = json.loads((Path(model_path) / "model_index.json").read_text(encoding="utf-8")) getattr(od_config, "task_type", None),
str(od_config.model),
)
model_root = _resolve_minimax_h3_model_root(
str(od_config.model),
od_config.revision,
self.partition,
)
model_path = model_root / ("Ref2VA" if self.partition == "ref2va" else "FL2VA")
model_index = json.loads((model_path / "model_index.json").read_text(encoding="utf-8"))
release = model_index.get("_minimax_h3") or {} release = model_index.get("_minimax_h3") or {}
self.partition = str(release.get("partition", "")) partition = str(release.get("partition", "")).lower()
self.supported_tasks = frozenset(release.get("tasks") or ()) expected_partition = "ref2va" if self.partition == "ref2va" else "fl2va"
if partition != expected_partition:
raise ValueError(f"invalid MiniMax-H3 {expected_partition} partition at {model_path}")
supported_tasks = {str(task).lower() for task in release.get("tasks", [])}
if not supported_tasks:
supported_tasks = {"ref2va"} if partition == "ref2va" else {"t2va", "fl2va"}
ref2va_model_path = None
if self.partition == "combined":
ref2va_model_path = model_root / "Ref2VA"
ref2va_index_path = ref2va_model_path / "model_index.json"
if not ref2va_index_path.is_file():
raise ValueError(f"Ref2VA partition not found at {ref2va_model_path}")
ref2va_index = json.loads(ref2va_index_path.read_text(encoding="utf-8"))
ref2va_release = ref2va_index.get("_minimax_h3") or {}
if str(ref2va_release.get("partition", "")).lower() != "ref2va":
raise ValueError(f"invalid MiniMax-H3 ref2va partition at {ref2va_model_path}")
supported_tasks.update(str(task).lower() for task in ref2va_release.get("tasks", ["ref2va"]))
self.supported_tasks = frozenset(supported_tasks)
shifts = release.get("sigma_shift_scales") or {} shifts = release.get("sigma_shift_scales") or {}
self.default_video_shift = float(shifts.get("video", 12.0)) self.default_video_shift = float(shifts.get("video", 12.0))
self.default_audio_shift = float(shifts.get("audio", 3.0)) self.default_audio_shift = float(shifts.get("audio", 3.0))
self.weights_sources = [ self.weights_sources = [
DiffusersPipelineLoader.ComponentSource( DiffusersPipelineLoader.ComponentSource(
model_or_path=model_path, model_or_path=str(model_path),
subfolder="transformer", subfolder="transformer",
revision=od_config.revision, revision=od_config.revision,
prefix="transformer.", prefix="transformer.",
fall_back_to_pt=False, fall_back_to_pt=False,
) )
] ]
self._dit_modules = ["transformer"]
if ref2va_model_path is not None:
self.weights_sources.append(
DiffusersPipelineLoader.ComponentSource(
model_or_path=str(ref2va_model_path),
subfolder="transformer",
revision=od_config.revision,
prefix="transformers_ref.",
fall_back_to_pt=False,
)
)
self._dit_modules.append("transformers_ref")
transformer_quant_config = _resolve_component_quant_config( transformer_quant_config = _resolve_component_quant_config(
od_config.quantization_config, od_config.quantization_config,
"transformer", "transformer",
@@ -524,14 +617,19 @@ class MiniMaxH3Pipeline(
od_config, od_config,
quant_config=transformer_quant_config, quant_config=transformer_quant_config,
) )
if ref2va_model_path is not None:
self.transformers_ref = MiniMaxH3DiTModel(
od_config,
quant_config=transformer_quant_config,
)
self.tokenizer = Qwen2TokenizerFast.from_pretrained( self.tokenizer = Qwen2TokenizerFast.from_pretrained(
model_path, str(model_path),
subfolder="tokenizer", subfolder="tokenizer",
local_files_only=os.path.isdir(model_path), local_files_only=os.path.isdir(model_path),
) )
self.processor = Qwen3VLProcessor.from_pretrained( self.processor = Qwen3VLProcessor.from_pretrained(
model_path, str(model_path),
subfolder="processor", subfolder="processor",
local_files_only=os.path.isdir(model_path), local_files_only=os.path.isdir(model_path),
) )
@@ -586,16 +684,23 @@ class MiniMaxH3Pipeline(
self, self,
weights: Iterable[tuple[str, torch.Tensor]], weights: Iterable[tuple[str, torch.Tensor]],
) -> set[str]: ) -> set[str]:
prefix = "transformer." def source_prefix(item: tuple[str, torch.Tensor]) -> str:
name, _ = item
prefix = name.partition(".")[0] + "."
if prefix in {"transformer.", "transformers_ref."}:
return prefix
raise ValueError(f"unexpected MiniMax-H3 weight {name!r}")
def transformer_weights(): loaded_with_prefix: set[str] = set()
for name, tensor in weights: loaded_prefixes: set[str] = set()
if name.startswith(prefix): for prefix, grouped_weights in groupby(weights, key=source_prefix):
yield name[len(prefix) :], tensor if prefix in loaded_prefixes:
raise ValueError(f"MiniMax-H3 weight source {prefix!r} is not contiguous")
loaded = self.transformer.load_weights(transformer_weights()) loaded_prefixes.add(prefix)
self.transformer.post_load_weights() transformer = getattr(self, prefix.removesuffix("."))
loaded_with_prefix = {prefix + name for name in loaded} loaded = transformer.load_weights((name[len(prefix) :], tensor) for name, tensor in grouped_weights)
transformer.post_load_weights()
loaded_with_prefix.update(prefix + name for name in loaded)
# The text encoder and both VAEs load eagerly in ``__init__`` rather # The text encoder and both VAEs load eagerly in ``__init__`` rather
# than through ``weights_sources``. Record them for the runner's strict # than through ``weights_sources``. Record them for the runner's strict
# missing-parameter check. # missing-parameter check.
@@ -604,14 +709,23 @@ class MiniMaxH3Pipeline(
loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters()) loaded_with_prefix.update(f"{component_name}.{name}" for name, _ in component.named_parameters())
return loaded_with_prefix return loaded_with_prefix
def _transformer_for_task(self, task: str) -> MiniMaxH3DiTModel:
if task == "ref2va" and hasattr(self, "transformers_ref"):
return self.transformers_ref
return self.transformer
def _resolve_task( def _resolve_task(
self, self,
requested: str | None, requested: str | None,
multi_modal_data: dict[str, Any], multi_modal_data: dict[str, Any],
) -> str: ) -> str:
if requested is None: if requested is None:
# A Ref2VA-only startup has no FL2VA transformer; preserve its
# historical implicit default even for image-only references.
if self.partition == "ref2va": if self.partition == "ref2va":
requested = "ref2va" requested = "ref2va"
elif multi_modal_data.get("video") is not None or multi_modal_data.get("audio") is not None:
requested = "ref2va"
elif multi_modal_data.get("image") is not None: elif multi_modal_data.get("image") is not None:
requested = "fl2va" requested = "fl2va"
else: else:
@@ -1226,14 +1340,14 @@ class MiniMaxH3Pipeline(
return video_rows, audio_rows return video_rows, audio_rows
@contextmanager @contextmanager
def _resident_dit_layers_on_device(self): def _resident_dit_layers_on_device(self, *, enabled: bool = True):
controller = getattr(self, "_dlo_residency_controller", None) controller = getattr(self, "_dlo_residency_controller", None)
if controller is not None: if controller is not None and enabled:
controller.load_resident_layers() controller.load_resident_layers()
try: try:
yield yield
finally: finally:
if controller is not None: if controller is not None and enabled:
controller.offload_resident_layers() controller.offload_resident_layers()
def diffuse( def diffuse(
@@ -1357,10 +1471,15 @@ class MiniMaxH3Pipeline(
num_steps=num_steps, num_steps=num_steps,
shift_scale=audio_shift, shift_scale=audio_shift,
) )
with self._resident_dit_layers_on_device(): transformer = self._transformer_for_task(task)
# The static DLO plan keeps leading blocks resident only for the
# primary ``transformer``. In combined mode ``transformers_ref`` is
# fully streamed, so a Ref2VA request must not stage the inactive
# FL2VA transformer resident blocks.
with self._resident_dit_layers_on_device(enabled=transformer is self.transformer):
with self.progress_bar(total=len(video_sigmas) - 1) as progress: with self.progress_bar(total=len(video_sigmas) - 1) as progress:
video_rows, audio_rows = minimax_h3_denoise_loop( video_rows, audio_rows = minimax_h3_denoise_loop(
model=self.transformer, model=transformer,
positive=branch, positive=branch,
initial_video_rows=initial_video, initial_video_rows=initial_video,
initial_audio_rows=initial_audio, initial_audio_rows=initial_audio,
@@ -1371,7 +1490,11 @@ class MiniMaxH3Pipeline(
device=self.device, device=self.device,
imgvid_cond_noise_aug_for_inference=(MINIMAX_H3_IMGVID_COND_TIMESTEP), imgvid_cond_noise_aug_for_inference=(MINIMAX_H3_IMGVID_COND_TIMESTEP),
audio_cond_noise_aug_for_inference=(MINIMAX_H3_AUDIO_REF_COND_TIMESTEP), audio_cond_noise_aug_for_inference=(MINIMAX_H3_AUDIO_REF_COND_TIMESTEP),
on_step=lambda step, video, audio: progress.update(), on_step_start=lambda step, video_sigma, audio_sigma: self.record_denoise_step(
step,
normalized_timestep=video_sigma,
),
on_step_end=lambda step, video, audio: progress.update(),
) )
target_video = video_rows[branch.update_mask_dev] target_video = video_rows[branch.update_mask_dev]
@@ -51,6 +51,13 @@ def _load_remote_component(
class_reference, class_reference,
component_path, component_path,
) )
# Build on the host regardless of the ambient default device. Online
# quantization wraps pipeline construction in a `with torch.device(<accel>)`
# block for the DiT's quantized linears, and the checkpoint's own VAE code
# builds constants with ops that have no accelerator kernel (BigVGAN's
# anti-aliasing filters call torch.kaiser_window). Callers place the module
# explicitly right after this returns, so nothing depends on the context.
with torch.device("cpu"):
return component_cls.from_pretrained(component_path) return component_cls.from_pretrained(component_path)
+11 -3
View File
@@ -91,6 +91,11 @@ _DIFFUSION_MODELS = {
"pipeline_minimax_h3", "pipeline_minimax_h3",
"MiniMaxH3Pipeline", "MiniMaxH3Pipeline",
), ),
"MiniMaxH3ModularPipeline": (
"minimax_h3",
"pipeline_minimax_h3",
"MiniMaxH3Pipeline",
),
"StableAudioPipeline": ( "StableAudioPipeline": (
"stable_audio", "stable_audio",
"pipeline_stable_audio", "pipeline_stable_audio",
@@ -427,9 +432,11 @@ def _apply_sequence_parallel_if_enabled(model, od_config: OmniDiffusionConfig) -
if sp_size <= 1: if sp_size <= 1:
return return
# Find transformer model(s) in the pipeline that have _sp_plan # Prefer the pipeline's declared DiT components so custom component
# Include transformer_2 for two-stage models (e.g., Wan MoE) # names receive the same SP hooks as conventional transformer names.
transformer_attrs = ["transformer", "transformer_2", "dit", "unet"] transformer_attrs = getattr(model, "_dit_modules", None)
if not transformer_attrs:
transformer_attrs = ("transformer", "transformer_2", "dit", "unet")
applied_count = 0 applied_count = 0
for attr in transformer_attrs: for attr in transformer_attrs:
@@ -508,6 +515,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = {
"LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func", "LTX2T2VDMD2Pipeline": "get_ltx2_post_process_func",
"LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func", "LTX2I2VDMD2Pipeline": "get_ltx2_post_process_func",
"MiniMaxH3Pipeline": "get_minimax_h3_post_process_func", "MiniMaxH3Pipeline": "get_minimax_h3_post_process_func",
"MiniMaxH3ModularPipeline": "get_minimax_h3_post_process_func",
"StableAudioPipeline": "get_stable_audio_post_process_func", "StableAudioPipeline": "get_stable_audio_post_process_func",
"SoulXSingerPipeline": "get_soulxsinger_post_process_func", "SoulXSingerPipeline": "get_soulxsinger_post_process_func",
"SoulXSingerSVCPipeline": "get_soulxsinger_post_process_func", "SoulXSingerSVCPipeline": "get_soulxsinger_post_process_func",
+27 -10
View File
@@ -7,6 +7,24 @@ from vllm.transformers_utils.config import get_hf_file_to_dict
logger = init_logger(__name__) logger = init_logger(__name__)
DIFFUSION_MODEL_INDEX_FILES = (
"model_index.json",
"modular_model_index.json",
)
def get_diffusion_model_index(
model_name: str,
*,
revision: str | None = None,
) -> dict | None:
"""Read the first standard Diffusers pipeline index available."""
for filename in DIFFUSION_MODEL_INDEX_FILES:
config = get_hf_file_to_dict(filename, model_name, revision=revision)
if isinstance(config, Mapping):
return dict(config)
return None
def load_diffusers_config(model_name) -> dict: def load_diffusers_config(model_name) -> dict:
from diffusers.pipelines.pipeline_utils import DiffusionPipeline from diffusers.pipelines.pipeline_utils import DiffusionPipeline
@@ -58,33 +76,32 @@ def is_diffusion_model(model_name: str) -> bool:
"""Check if a model is a diffusion model. """Check if a model is a diffusion model.
Uses multiple fallback strategies to detect diffusion models: Uses multiple fallback strategies to detect diffusion models:
1. Check local file system for model_index.json (fastest, no imports) 1. Check the local file system for a standard Diffusers index
2. Check using vllm's get_hf_file_to_dict utility 2. Check using vllm's get_hf_file_to_dict utility
3. Try the standard diffusers approach (may fail due to import issues) 3. Try the standard diffusers approach (may fail due to import issues)
""" """
# Strategy 1: Check local file system first (fastest, avoids import issues) # Strategy 1: Check local file system first (fastest, avoids import issues)
if os.path.isdir(model_name): if os.path.isdir(model_name):
model_index_path = os.path.join(model_name, "model_index.json") for filename in DIFFUSION_MODEL_INDEX_FILES:
if os.path.exists(model_index_path): model_index_path = os.path.join(model_name, filename)
if not os.path.exists(model_index_path):
continue
try: try:
import json import json
with open(model_index_path) as f: with open(model_index_path) as f:
config_dict = json.load(f) config_dict = json.load(f)
if config_dict.get("_class_name") and config_dict.get("_diffusers_version"): if config_dict.get("_class_name") and config_dict.get("_diffusers_version"):
logger.debug("Detected diffusion model via local model_index.json") logger.debug("Detected diffusion model via local %s", filename)
return True return True
except Exception as e: except Exception as e:
logger.debug("Failed to read local model_index.json: %s", e) logger.debug("Failed to read local %s: %s", filename, e)
# Strategy 2: Check using vllm's utility (works for both local and remote models) # Strategy 2: Check using vllm's utility (works for both local and remote models)
try: config_dict = get_diffusion_model_index(model_name)
config_dict = get_hf_file_to_dict("model_index.json", model_name)
if config_dict is not None and config_dict.get("_class_name") and config_dict.get("_diffusers_version"): if config_dict is not None and config_dict.get("_class_name") and config_dict.get("_diffusers_version"):
logger.debug("Detected diffusion model via model_index.json") logger.debug("Detected diffusion model via a standard Diffusers index")
return True return True
except Exception as e:
logger.debug("Failed to check model_index.json via get_hf_file_to_dict: %s", e)
# Strategy 3: Try the standard diffusers approach (may fail due to import issues) # Strategy 3: Try the standard diffusers approach (may fail due to import issues)
# This is last because it requires importing diffusers/xformers/flash_attn # This is last because it requires importing diffusers/xformers/flash_attn
@@ -280,8 +280,11 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin):
exc, exc,
) )
else: else:
self._compile_transformer("transformer") transformer_attrs = getattr(self.pipeline, "_dit_modules", None)
self._compile_transformer("transformer_2") if not transformer_attrs:
transformer_attrs = ("transformer", "transformer_2")
for attr_name in transformer_attrs:
self._compile_transformer(attr_name)
else: else:
logger.warning( logger.warning(
"Model runner: Platform %s does not support torch inductor, skipping torch.compile.", "Model runner: Platform %s does not support torch inductor, skipping torch.compile.",
+3 -2
View File
@@ -140,8 +140,9 @@ class OmniEngineArgs(EngineArgs):
stage_connector_spec: Extra configuration for stage connector stage_connector_spec: Extra configuration for stage connector
async_chunk: If set to True, perform async chunk async_chunk: If set to True, perform async chunk
worker_type: Model Type, e.g., "ar" or "generation" worker_type: Model Type, e.g., "ar" or "generation"
task_type: Default task type for TTS models (CustomVoice, VoiceDesign, or Base). task_type: Model-defined startup task type. Consumers validate the
If not specified, will be inferred from model path. supported values and decide whether it selects request behavior,
task-specific weights, or both.
omni_master_address: TCP address that the OmniMasterServer (running omni_master_address: TCP address that the OmniMasterServer (running
inside AsyncOmniEngine) listens on for engine core registrations. inside AsyncOmniEngine) listens on for engine core registrations.
Required when single-stage mode is active. Required when single-stage mode is active.
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more