Merge branch 'main' into perf/minimax-h3-lossless-hot-path

This commit is contained in:
WeiQing Chen authored and GitHub committed 2026-08-07 11:27:30 +08:00
commit 5ed153947b
143 files changed
+8587 -2165

No files matched your search

+15
View File
@@ -460,6 +460,21 @@ steps:
exit $$EXIT2
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"
key: nightly-diffusion-test-group
depends_on: upload-nightly-pipeline
+1 -1
View File
@@ -309,7 +309,7 @@ For Omni or custom models, create:
Required updates:
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
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`)
+4 -4
View File
@@ -16,15 +16,15 @@
/.claude/ @hsliuustc0106
# Tests
/tests/ @yenuo26
/tests/ @yenuo26 @NickCao
# CI
/.buildkite/ @yenuo26 @congw729
/.buildkite/ @yenuo26 @congw729 @NickCao
# Engine and model_executor
/vllm_omni/core/ @tzhouam @yinpeiqi @fake0fan @Sy0307
/vllm_omni/engine/ @tzhouam @yinpeiqi @fake0fan @Sy0307
/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan @alex-jw-brooks
/vllm_omni/entrypoints/ @tzhouam @yinpeiqi @fake0fan @alex-jw-brooks @NickCao
/vllm_omni/model_executor/ @tzhouam @gcanlin
/vllm_omni/worker/ @tzhouam @gcanlin
@@ -37,7 +37,7 @@
# Omni and TTS support
/vllm_omni/transformers_utils/ @tzhouam @yuanheng-zhao
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin
/vllm_omni/model_executor/models/ @linyueqian @ZeldaHuang @princepride @yuanheng-zhao @amy-why-3459 @Bounty-hunter @Sy0307 @gcanlin @NickCao
# Benchmarks
/benchmarks/accuracy/ @david6666666
+27 -7
View File
@@ -1,5 +1,6 @@
import asyncio
import base64
import json
import mimetypes
import os
import time
@@ -13,6 +14,17 @@ from tqdm import tqdm
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
class RequestFuncInput:
prompt: str
@@ -355,13 +367,21 @@ async def async_request_v1_videos(
)
elif input.video_paths and len(input.video_paths) > 0:
video_path = input.video_paths[0]
image_file = open(video_path, "rb")
form.add_field(
"input_reference",
image_file,
filename=os.path.basename(video_path),
content_type=_guess_mime_type(video_path),
)
if _is_minimax_h3_mixed_reference(input.model):
with open(video_path, "rb") as reference_file:
video_b64 = base64.b64encode(reference_file.read()).decode("ascii")
form.add_field(
"video_reference",
json.dumps([{"video_url": f"data:video/mp4;base64,{video_b64}"}]),
)
else:
image_file = open(video_path, "rb")
form.add_field(
"input_reference",
image_file,
filename=os.path.basename(video_path),
content_type=_guess_mime_type(video_path),
)
job_id = None
job_status = None
@@ -858,7 +858,12 @@ class RandomDataset(BaseDataset):
height = int(self.args.height or profile.get("height") or 720)
num_frames = int(self.args.num_frames or profile.get("num_frames") or 16)
fps = float(self.args.fps or profile.get("fps") or 8)
writer = cv2.VideoWriter(video_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
# 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():
raise RuntimeError("cv2.VideoWriter failed to open")
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)
writer.write(frame)
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]
except Exception as e:
raise RuntimeError(
@@ -878,6 +887,40 @@ class RandomDataset(BaseDataset):
) 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:
"""Compute expected execution time (ms) based on a base per-step-per-frame unit time.
+44 -28
View File
@@ -68,13 +68,12 @@ nav:
- Sequence Parallel: user_guide/diffusion/parallelism/sequence_parallel.md
- Tensor Parallel: user_guide/diffusion/parallelism/tensor_parallel.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
- Custom Pipeline: features/custom_pipeline.md
- Request-Level Batching: user_guide/diffusion/request_batching.md
- Step Execution: user_guide/diffusion/step_execution.md
- Execution Modes: user_guide/diffusion/execution_modes.md
- Quantization:
- Overview: user_guide/quantization/overview.md
- Quantization: user_guide/quantization/overview.md
- Online Quantization: user_guide/quantization/online.md
- Quantized KV Cache: user_guide/quantization/quantized_kvcache.md
- FP8 W8A8: user_guide/quantization/fp8.md
@@ -102,32 +101,49 @@ nav:
- CI: contributing/ci
- Design Documents:
- design/index.md
- design/architecture_overview.md
- Architecture Documents:
- Architecture Overview: design/architecture_overview.md
- Feature Design:
- design/feature/disaggregated_inference.md
- design/feature/ray_based_execution.md
- design/feature/omni_connectors/
- design/feature/prefix_caching.md
- design/feature/cfg_parallel.md
- design/feature/expert_parallel.md
- design/feature/sequence_parallel.md
- design/feature/tensor_parallel.md
- design/feature/vae_parallel.md
- design/feature/hsdp.md
- design/feature/cache_dit.md
- design/feature/teacache.md
- design/feature/skip_softmax.md
- design/feature/async_chunk.md
- design/feature/omni_async_output_materialization.md
- design/feature/vae_parallel.md
- design/feature/diffusion_step_execution.md
- design/feature/diffusion_request_level_batching.md
- design/feature/diffusion_continuous_batching.md
- Runtime and Stage Execution:
- Disaggregated Inference: design/feature/disaggregated_inference.md
- Async Chunk: design/feature/async_chunk.md
- Async Diffusion Output: design/feature/async_diffusion_output.md
- Async Omni Output Materialization: design/feature/omni_async_output_materialization.md
- Automatic Prefix Caching: design/feature/prefix_caching.md
- Communication:
- OmniConnector Implementations:
- Mooncake Store Connector: design/feature/omni_connectors/mooncake_store_connector.md
- Mooncake Transfer Engine Connector: design/feature/omni_connectors/mooncake_transfer_engine_connector.md
- Mori Transfer Engine Connector: design/feature/omni_connectors/mori_transfer_engine_connector.md
- Shared Memory Connector: design/feature/omni_connectors/shared_memory_connector.md
- Yuanrong Store Connector: design/feature/omni_connectors/yuanrong_connector.md
- Yuanrong Transfer Engine Connector: design/feature/omni_connectors/yuanrong_transfer_engine_connector.md
- Diffusion Acceleration:
- Parallelism:
- CFG-Parallel: design/feature/cfg_parallel.md
- Expert Parallel: design/feature/expert_parallel.md
- Hybrid Sharded Data Parallel (HSDP): design/feature/hsdp.md
- Pipeline Parallel: design/feature/pipeline_parallel.md
- Sequence Parallel: design/feature/sequence_parallel.md
- Tensor Parallel: design/feature/tensor_parallel.md
- VAE Patch Parallelism: design/feature/vae_parallel.md
- Attention Backends:
- Skip-Softmax: design/feature/skip_softmax.md
- Quantization: design/feature/quantization.md
- Cache-DiT: design/feature/cache_dit.md
- TeaCache: design/feature/teacache.md
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.md
- Distributed Layerwise Offload: design/feature/distributed_layerwise_offload.md
- Infrastructure and Performance:
- Prometheus Metrics: design/metrics.md
- Speech Generation Performance Optimizations: design/qwen3_omni_tts_performance_optimization.md
- Module Design:
- design/module/ar_module.md
- design/module/dit_module.md
- design/module/entrypoint_module.md
- design/module/async_omni_architecture.md
- Runtime Modules:
- AR Module: design/module/ar_module.md
- DIT Module: design/module/dit_module.md
- Orchestration:
- Entrypoint Module: design/module/entrypoint_module.md
- AsyncOmni Architecture: design/module/async_omni_architecture.md
- Docs Guide: contributing/DOCS_GUIDE.md
- API Reference:
- api/README.md
+1
View File
@@ -40,6 +40,7 @@ Committers have write access and merge rights. They typically have deep expertis
- [@Isotr0py](https://github.com/Isotr0py): Diffusion and Quantization
- [@linyueqian](https://github.com/linyueqian): TTS and Omni Support
- [@lishunyang12](https://github.com/lishunyang12): Quantization and Configuration
- [@NickCao](https://github.com/NickCao): CI, Tests, Entrypoint, and TTS Model Support
- [@princepride](https://github.com/princepride): Diffusion and Omni Support
- [@RuixiangMa](https://github.com/RuixiangMa): Diffusion models, parallel, cache, and docs
- [@SamitHuang](https://github.com/SamitHuang): RL, Diffusion, and cache
+2 -2
View File
@@ -387,8 +387,8 @@ that support request-level batching with `step_execution` disabled.
Use this together with `max_num_seqs > 1` for bursty serving traffic. `0`
disables admission waiting and preserves the lowest first-request latency.
For diffusion request-level batching tuning, see
[Request-Level Batching](../user_guide/diffusion/request_batching.md).
For diffusion execution and batching tuning, see
[Diffusion Execution Modes](../user_guide/diffusion/execution_modes.md).
Default: `0.0`
@@ -1,6 +1,6 @@
**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**
@@ -44,9 +44,11 @@ Pass **`--test-config-file`** to run one JSON file as-is, or omit it for the bul
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [500, 800],
"mean_audio_ttfp_ms": [2000, 3500],
"mean_audio_rtf": [0.25, 0.35]
"H100": {
"mean_ttft_ms": [500, 800],
"mean_audio_ttfp_ms": [2000, 3500],
"mean_audio_rtf": [0.25, 0.35]
}
}
}
]
@@ -142,10 +144,6 @@ pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_bagel_vllm_omni.json
pytest -s -v tests/dfx/perf/scripts/run_benchmark.py \
--test-config-file tests/dfx/perf/tests/test_qwen3_omni_async_chunk.json
# Optional baseline assertion (default off)
pytest -sv tests/dfx/perf/scripts/run_diffusion_benchmark.py --assert-baseline \
--test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
```
See also [Markers for Tests](#markers-for-tests) for registered hardware markers (`H100`, `L4`, `cuda`, `distributed_cuda`, …).
@@ -202,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. |
| 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). |
| baseline | object | No | see above | Optional per-metric thresholds; keys must match benchmark output fields. Scalar, list (per sweep step), or object (keyed by concurrency or QPS string).
| 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. |
@@ -755,7 +755,8 @@ omni = Omni(model="your-model", ulysses_degree=2, ring_degree=2)
### Step Execution
See detailed design guide: [How to add step execution support](../../design/feature/diffusion_step_execution.md)
See the detailed
[Diffusion Continuous Batching design guide](../../design/feature/diffusion_continuous_batching.md).
Use this only when your pipeline can be split into stable request-scoped and
step-scoped phases. The reference implementation is
@@ -769,9 +770,8 @@ step-scoped phases. The reference implementation is
Do not enable `step_execution=True` until those four methods are implemented
and validated against the request-level path.
If you want the pipeline to work with the experimental batched step-wise path
(`max_num_seqs > 1`), also see:
[Continuous Batching for Step-Wise Diffusion](../../design/feature/diffusion_continuous_batching.md).
The same design guide covers the experimental batched step-wise path used when
`max_num_seqs > 1`.
If you expose this in example scripts or recipes, keep it opt-in. Surface
runtime features like `step_execution` as optional flags instead of silently
@@ -824,7 +824,7 @@ omni = Omni(model="your-model",
### 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:
@@ -1,165 +1,232 @@
# Continuous Batching for Step-Wise Diffusion
# Diffusion Continuous Batching
!!! warning "Experimental Feature"
This feature is experimental. It currently applies only to native
diffusion pipelines running with `step_execution=True`.
This document describes the batching extension built on top of
[Diffusion Step Execution](diffusion_step_execution.md). The base
step-execution contract is unchanged. The batching work is mainly in the
scheduler and runner layers.
## Why It Helps
Step-wise execution breaks a long denoise loop into scheduler-visible units.
That gives the runtime a place to admit other compatible requests between
steps instead of waiting for an entire request to finish.
This matters most in low-MFU or bursty serving scenarios:
- one request's denoise step may not fully saturate the GPU
- several compatible requests can share the same denoise forward pass
- throughput and device utilization can improve without changing request-local
scheduler state
This is **not** a guaranteed single-request latency win. The main benefit is
usually higher utilization and better throughput when the workload contains
multiple in-flight compatible requests.
This document describes the unified diffusion execution architecture, including
request execution, request-level batching, step execution, continuous batching,
and output delivery. For user-facing configuration and CLI examples, see
[Diffusion Execution Modes](../../user_guide/diffusion/execution_modes.md).
## Overview
With continuous batching enabled:
`DiffusionEngine` selects an execution mode from `step_execution` and uses
`max_num_seqs` as that mode's scheduler capacity:
- the scheduler may keep multiple compatible requests active at the same time
- the runner packs request-local step state into one `InputBatch`
- `denoise_step()` runs on that batch
- `step_scheduler()` and `post_decode()` still run per request
| Configuration | Engine mode | Scheduler | Execution |
|---|---|---|---|
| `step_execution=False`, `max_num_seqs=1` | `REQUEST_BATCH` | `RequestScheduler` | One complete request-level `forward()` |
| `step_execution=False`, `max_num_seqs>1` | `REQUEST_BATCH` | `RequestScheduler` | One fused `forward()` over compatible requests |
| `step_execution=True`, `max_num_seqs=1` | `STEP_BATCH` | `StepScheduler` | One request advanced one denoise step per scheduler tick |
| `step_execution=True`, `max_num_seqs>1` | `STEP_BATCH` | `StepScheduler` | Compatible requests advanced together in step waves |
The current implementation is conservative:
Serial request execution is handled inside `REQUEST_BATCH`; it is not a
separate engine mode. Similarly, continuous batching is the multi-request
configuration of `STEP_BATCH`, not an independent execution mode.
- only compatible requests are batched together
- per-request progress and completion remain independent
The engine performs the following initialization:
Here, "continuous batching" means the step-wise path enabled by
`step_execution=True`. Request-mode `DiffusionRequestBatch` is static
request-level batching for one full pipeline `forward()` call; it does not
admit or remove requests between denoise steps.
1. Resolve model-specific pre- and post-processing hooks.
2. Select `REQUEST_BATCH` or `STEP_BATCH`.
3. Construct the configured `DiffusionExecutor`.
4. Construct `RequestScheduler` or `StepScheduler`, unless a scheduler was
explicitly injected.
5. Bind `execute_batch` or `execute_step`.
## Enablement
`DiffusionEngine.make_engine()` owns startup warmup after engine construction.
This keeps construction, backend selection, and warmup failure cleanup in one
lifecycle.
Use `--step-execution` as the feature gate, then increase `--max-num-seqs`
above `1` if you want batching:
## Unified Output Stream
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--step-execution \
--max-num-seqs 8
All asynchronous diffusion requests use the same per-request output stream,
regardless of whether the caller exposes streaming results to the user.
```python
async for output in engine.step_streaming(request):
...
```
`--max-num-seqs 1` keeps the step-wise path without enabling batching.
When a request is admitted, the engine creates an
`asyncio.Queue[DiffusionOutput]` keyed by its request ID. The busy loop sends
both intermediate chunks and final results through that queue:
For a reproducible replay flow using the bundled serving benchmark, see the
Qwen-Image replay commands in
[`benchmarks/diffusion/README.md`](gh-file:benchmarks/diffusion/README.md)
and
[`benchmarks/diffusion/performance_dashboard/qwen_image_serving_performance.md`](gh-file:benchmarks/diffusion/performance_dashboard/qwen_image_serving_performance.md).
```text
request
-> scheduler.add_request()
-> scheduler.schedule()
-> execute_batch() or execute_step()
-> scheduler.update_from_output()
-> per-request output queue
-> step_streaming()
```
## Scheduler
Streaming callers forward every yielded output. Non-streaming callers consume
the same generator to completion and return only its final output. This avoids
maintaining separate future-based and queue-based result paths.
The scheduler derives its batch capacity from `max_num_seqs` through
`max_num_running_reqs`.
`DiffusionEngine.step()` and
`async_add_req_and_wait_for_response()` remain deprecated compatibility
wrappers. New integrations should consume `step_streaming()` or
`async_add_req_and_stream_response()`.
Batch admission is gated by
[`StepBatchSamplingParamsKey`](gh-file:vllm_omni/diffusion/sched/interface.py),
which is built from shape-sensitive and CFG-sensitive sampling fields. This is
the core correctness rule for batching: requests are only co-batched when they
share the same denoise tensor contract.
### Completion and Cancellation
There are three important details:
Output delivery and scheduler lifecycle are related but separate:
- `num_inference_steps` is not part of the key, so requests with different
total step counts can still share a batch
- requests also do not need to be at the same current denoise progress; active
requests can continue batching even when their current step indices diverge
- admission is still FIFO, so an incompatible request at the head of the
waiting queue blocks later compatible requests
- A terminal scheduler result always finalizes request state.
- A disconnected or cancelled consumer removes only its delivery queue.
- The scheduler may still finish and clean up the request after its queue has
been removed.
- Engine shutdown sends an error output to all remaining streams.
Today that compatibility rule is still shape-sensitive. `height`, `width`,
`num_frames`, and CFG-related fields remain part of the key, so different
resolutions or incompatible guidance settings do **not** co-batch yet. The
key also covers LoRA identity (`lora_int_id`, `lora_scale`), so requests
targeting different adapters or scales run in separate batches and the
worker can activate exactly one adapter per step.
This separation prevents cancelled consumers from leaving active request IDs in
the scheduler.
The scheduler batching unit is one logical `OmniDiffusionRequest`. In the
step-wise path, runtime tensor batching is represented as `StepInputBatch`. For
request-mode prompt semantics, see
[Request-Level Batching](../../user_guide/diffusion/request_batching.md).
## Request-Batch Execution
## Runner
Request-batch execution runs the complete diffusion pipeline for each scheduler
wave. Each `OmniDiffusionRequest` remains an independent logical request with
its own prompt, sampling parameters, seed, request ID, output, error, and abort
state.
The runner keeps persistent per-request execution state in
[`StepRequestState`](gh-file:vllm_omni/diffusion/worker/utils.py),
while the scheduler owns a separate lightweight
[`SchedulerRequestState`](gh-file:vllm_omni/diffusion/sched/interface.py)
for queueing and lifecycle tracking.
With `max_num_seqs=1`, `execute_batch()` handles the serial request path. With a
larger value, a pipeline can opt into fused execution by declaring:
For each step, the runner builds an
[`InputBatch`](gh-file:vllm_omni/diffusion/worker/input_batch.py) from the
active request states:
```python
supports_request_batch = True
```
- prompt embeddings and masks are normalized and padded
- dynamic tensors such as `latents` and `timesteps` are gathered each step
- buffers are reused when batch composition stays the same
Its `forward()` method must accept `DiffusionRequestBatch` and return one
`DiffusionOutput` per request. The runner validates the result count and maps
outputs back to their original request IDs with `BatchRunnerOutput`.
The step-wise batched path is:
Current in-tree request-batch implementations include the Flux, LTX-2, SD3, and
Qwen-Image pipelines. Treat the pipeline capability flag in the source as
authoritative because support continues to expand.
1. Run `prepare_encode()` for newly admitted requests.
### Request-Batch Data Flow
- `DiffusionSchedulerOutput` carries scheduled request IDs and payloads.
- `DiffusionRequestBatch` provides the pipeline-facing static batch.
- `BatchRunnerOutput` carries per-request results back to the engine.
Request-local setup, such as seed handling and output/error mapping, remains
per request. Cache refresh, homogeneous LoRA activation, and
`pipeline.forward(req_batch)` can be shared by the batch.
### Compatibility and Admission
`RequestBatchSamplingParamsKey` contains fields that affect the tensor contract,
including shape, guidance, output count, and LoRA identity. Only compatible
requests can share a fused forward call.
Admission is FIFO and conservative. An incompatible request at the front of the
waiting queue can prevent later compatible requests from joining the current
wave.
`request_batch_max_wait_ms` optionally delays the first schedule operation of a
new wave so bursty arrivals can accumulate. It applies only when:
- the pipeline supports request batching;
- step execution is disabled;
- no requests are currently running; and
- the configured wait is greater than zero.
The wait ends when the queue reaches capacity, stabilizes briefly, reaches its
deadline, or the engine stops.
## Step-Batch Execution
Step execution exposes denoising progress to the scheduler. A supporting
pipeline implements four stateful operations:
| Operation | Responsibility |
|---|---|
| `prepare_encode(state)` | Validate input, encode prompts, initialize latents and timesteps, and create request-local scheduler state |
| `denoise_step(input_batch, *, states=...)` | Run one denoise forward for the scheduler-provided request states |
| `step_scheduler(state, noise_pred)` | Update latents and advance request progress |
| `post_decode(state)` | Decode and postprocess a completed request or output boundary |
Persistent request state lives in `StepRequestState`. Pipeline-specific fields
that do not belong in the shared contract should be stored in `state.extra`.
Queueing and lifecycle metadata remain in the scheduler's request state.
Current native pipelines that explicitly enable step execution include
Qwen-Image, HunyuanImage3, and Helios. Step execution alone does not imply
continuous-batching support: Qwen-Image accepts batched step states.
HunyuanImage3 accepts batched step states only when its resolved self-attention
backend is `TORCH_SDPA`; otherwise it rejects groups larger than one request.
Configure `DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA` or
`diffusion_attention_config.default.backend=TORCH_SDPA` when
`max_num_seqs>1`. See the
[HunyuanImage-3.0 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanImage-3.0-Instruct.md)
for its validated configuration. Helios supports only a single active step
request and must use `max_num_seqs=1`.
### Continuous Batching
When `max_num_seqs>1`, `StepScheduler` can keep multiple compatible requests
active. The runner gathers their state into `InputBatch`, performs one batched
denoise forward, then applies scheduler updates per request:
1. Receive transferred KV payloads, then run `prepare_encode()` for newly
admitted requests.
2. Build or refresh `InputBatch`.
3. Run one batched `denoise_step(input_batch)`.
4. Slice the batched `noise_pred` back per request.
5. Run per-request `step_scheduler()`.
6. Run `post_decode()` only for requests that finished denoising.
7. Scatter updated latents back into persistent request state with
[`scatter_latents()`](gh-file:vllm_omni/diffusion/worker/input_batch.py).
3. Run one batched `denoise_step(input_batch, states=states)`.
4. Slice noise predictions back to each request.
5. Run each request's `step_scheduler()`.
6. Run `post_decode()` at a chunk boundary or request completion.
7. Scatter updated latents back into persistent request state.
This keeps the shared work limited to the denoise forward pass while preserving
request-local scheduler state and outputs.
`StepBatchSamplingParamsKey` protects the batched tensor contract. Requests can
have different total step counts and current step indices, but shape-sensitive,
CFG-sensitive, and LoRA fields must be compatible. FIFO head-of-line blocking
also applies here.
## Engine
Chunk-capable pipelines may emit intermediate results. Final-only step
pipelines emit when the request finishes even if `streaming_output=True`.
[`DiffusionEngine`](gh-file:vllm_omni/diffusion/diffusion_engine.py) provides
the background loop and async add-request path needed for multiple requests to
accumulate in the scheduler.
## Model Author Guidelines
When `step_execution=True`, the engine routes work through the step-wise
executor path. The continuous batching behavior is defined by scheduler-side
compatibility gating and runner-side `StepInputBatch` packing.
When converting a request-level pipeline:
## Current Limitations
- Reuse the same helpers as `forward()` to avoid behavior drift.
- Copy request-scoped scheduler state into `state.scheduler`.
- Advance `state.step_index` only in `step_scheduler()`.
- Keep model forward work in `denoise_step()`.
- Keep latent mutation in `step_scheduler()`.
- Keep final decoding equivalent to the tail of `forward()`.
- Store masks, condition latents, and other request-local tensors in the
request state rather than on the shared pipeline.
- Experimental feature; use `max_num_seqs=1` for the conservative single-request
step path.
- Only native pipelines that already support `step_execution=True`.
- Only homogeneous batches keyed by `StepBatchSamplingParamsKey` are supported.
- `cache_backend`, KV transfer, and other request-mode extras are not wired
into the batched step-wise path yet.
- Future work can relax the current same-shape restriction with richer
heterogeneous batching policies such as bucketing or padded execution for
different resolutions.
Before setting `supports_step_execution = True`, validate output parity for the
same seed and sampling parameters, scheduler isolation across concurrent
requests, abort cleanup, progress reporting, and all supported CFG paths.
## Related Files
Before setting `supports_request_batch = True`, validate the batch input
contract, one-output-per-request result shape, request identity and error
mapping, seeded concurrency, LoRA compatibility, and tensor IPC.
- Scheduler base:
[`vllm_omni/diffusion/sched/base_scheduler.py`](gh-file:vllm_omni/diffusion/sched/base_scheduler.py)
- Scheduler interface:
[`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py)
- Step scheduler:
[`vllm_omni/diffusion/sched/step_scheduler.py`](gh-file:vllm_omni/diffusion/sched/step_scheduler.py)
- Runner:
[`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py)
- Input batch:
[`vllm_omni/diffusion/worker/input_batch.py`](gh-file:vllm_omni/diffusion/worker/input_batch.py)
- Tests:
[`tests/diffusion/test_diffusion_scheduler.py`](gh-file:tests/diffusion/test_diffusion_scheduler.py)
## Limitations
- Request batching requires an explicit pipeline capability when
`max_num_seqs>1`.
- Step execution requires the four-method stateful pipeline contract.
- Both batching paths currently require homogeneous compatibility keys.
- FIFO scheduling can reduce batching opportunities.
- `request_batch_max_wait_ms` trades first-request latency for burst
coalescing.
- All diffusion cache backends are currently unsupported in step mode.
- KV transfer is supported for newly admitted step requests; some other
request-mode extras remain unsupported.
- Step continuous batching remains experimental; use `max_num_seqs=1` for the
conservative step path.
## Related Implementation
- Engine: [`vllm_omni/diffusion/diffusion_engine.py`](gh-file:vllm_omni/diffusion/diffusion_engine.py)
- Model contract: [`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py)
- Scheduler interface: [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py)
- Request scheduler: [`vllm_omni/diffusion/sched/request_scheduler.py`](gh-file:vllm_omni/diffusion/sched/request_scheduler.py)
- Step scheduler: [`vllm_omni/diffusion/sched/step_scheduler.py`](gh-file:vllm_omni/diffusion/sched/step_scheduler.py)
- Runner: [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py)
- Request batch: [`vllm_omni/diffusion/worker/request_batch.py`](gh-file:vllm_omni/diffusion/worker/request_batch.py)
- Step input batch: [`vllm_omni/diffusion/worker/input_batch.py`](gh-file:vllm_omni/diffusion/worker/input_batch.py)
@@ -1,175 +0,0 @@
# Request-Level Batching for Diffusion
This document describes the request-mode batching path for diffusion pipelines.
For end-user enablement and tuning, see
[Request-Level Batching](../../user_guide/diffusion/request_batching.md).
This is separate from
[Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md).
Request-level batching runs one full pipeline `forward()` over a static batch of
compatible requests. Step-wise continuous batching admits work between denoise
steps when `step_execution=True`.
## Why It Helps
The request-level design avoids coupling several logical requests to one request
object. This keeps request identity, abort/error handling, and per-request
metadata unambiguous while still allowing one fused pipeline forward pass for
bursty or concurrent traffic.
## Overview
With request-level batching enabled:
- each `OmniDiffusionRequest` contains one `prompt` and one `request_id`
- the scheduler groups compatible waiting requests into one scheduler wave
- `DiffusionRequestBatch` wraps the scheduled requests for pipeline `forward()`
- batch-capable pipelines return `list[DiffusionOutput]`, one output per
request
- `BatchRunnerOutput` maps each result back to its original `request_id`
Pipelines opt in with `supports_request_batch = True` and a `forward()` method
that accepts `DiffusionRequestBatch` and returns `list[DiffusionOutput]`.
Pipelines that do not opt in keep the existing per-request execution path.
## Enablement
Request-level batching is the request-mode path, so `step_execution` must remain
disabled. Increase `max_num_seqs` above `1` to let the scheduler keep multiple
compatible requests active:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4
```
For bursty online ingress, `request_batch_max_wait_ms` can add a bounded
admission wait before the first `schedule()` of a scheduler wave:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4 \
--request-batch-max-wait-ms 20
```
`request_batch_max_wait_ms=0` disables this wait and is the default.
## Request Contract
`OmniDiffusionRequest` represents one logical request. It owns one prompt,
sampling parameters, request id, and request-local metadata. Runtime batches are
formed by the scheduler and represented separately from the request payload.
Runtime batching is represented by:
- [`DiffusionSchedulerOutput`](gh-file:vllm_omni/diffusion/sched/interface.py)
for scheduled request ids and request payloads
- [`DiffusionRequestBatch`](gh-file:vllm_omni/diffusion/worker/request_batch.py)
for the pipeline-facing request batch
- [`BatchRunnerOutput`](gh-file:vllm_omni/diffusion/worker/utils.py) for
per-request results
`DiffusionRequestBatch` intentionally exposes compatibility properties such as
`prompts`, `sampling_params`, `request_id`, and `kv_sender_info` so migrated
pipelines can stay close to upstream code while using a batch-aware contract.
## Scheduler
The scheduler derives its capacity from `max_num_seqs` through
`max_num_running_reqs`. It exposes waiting/running queue counters so the engine
can decide whether admission wait is useful before scheduling a new wave.
Batch compatibility is controlled by
[`RequestBatchSamplingParamsKey`](gh-file:vllm_omni/diffusion/sched/interface.py). The key
contains shape-sensitive and guidance-sensitive fields, including output count
and LoRA identity. Requests with incompatible shapes, CFG settings, output
counts, LoRA adapters, or LoRA scales are kept in separate batches.
Admission is conservative:
- the scheduler only batches compatible requests
- FIFO ordering is preserved
- an incompatible request at the head of the waiting queue blocks later
compatible requests
## Engine
[`DiffusionEngine`](gh-file:vllm_omni/diffusion/diffusion_engine.py) resolves
request-batch capability during initialization from the configured pipeline
class, including custom pipeline classes.
The capability check uses the pipeline class attribute
`supports_request_batch = True`. Pipelines that set this attribute must implement
a request-batch-compatible `forward()` contract and return one
`DiffusionOutput` per request; the runner validates that return shape at runtime.
When the selected pipeline is batch-capable and `step_execution=False`, request
mode routes scheduler waves through the batch executor path. Otherwise it keeps
the per-request executor path.
The optional admission wait runs only when:
- request batching is supported
- `step_execution=False`
- `request_batch_max_wait_ms > 0`
- no requests are currently running
The wait exits early when the waiting queue reaches capacity, when the queue is
stable for a short window, when the deadline expires, or when the engine stops.
## Executor And Runner
The executor exposes two request-mode entries:
- `execute_request`: one worker call per scheduled request
- `execute_batch`: one worker call for the whole `DiffusionSchedulerOutput`
On the batch path, the worker builds a `DiffusionRequestBatch` and runs the
pipeline once. Request-local setup remains per request:
- KV transfer metadata
- random generator and seed handling
- request output/error/abort mapping
Shared batch setup happens once per batch when possible:
- cache refresh
- LoRA activation for the homogeneous adapter key
- pipeline `forward(req_batch)`
Large tensor IPC still uses the shared-memory packing path. The packer traverses
both normal `RunnerOutput.result` wrappers and nested batch results so batched
outputs do not fall back to pickle IPC for tensor payloads.
## Current Limitations
- Only pipelines that declare the request-batch contract use fused batch
execution.
- Batches are homogeneous under `RequestBatchSamplingParamsKey`; heterogeneous resolution or
incompatible guidance settings do not co-batch yet.
- FIFO scheduling can reduce batching opportunities when an incompatible
request is at the front of the queue.
- `request_batch_max_wait_ms` improves burst coalescing but can add latency to
the first request in a scheduler wave. Keep it small for latency-sensitive
serving.
- Step-wise continuous batching is documented separately and only applies when
`step_execution=True`.
## Related Files
- Request object and request batch:
[`vllm_omni/diffusion/request.py`](gh-file:vllm_omni/diffusion/request.py)
- Scheduler interface:
[`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py)
- Scheduler base:
[`vllm_omni/diffusion/sched/base_scheduler.py`](gh-file:vllm_omni/diffusion/sched/base_scheduler.py)
- Engine:
[`vllm_omni/diffusion/diffusion_engine.py`](gh-file:vllm_omni/diffusion/diffusion_engine.py)
- Worker runner:
[`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py)
- Executor interface:
[`vllm_omni/diffusion/executor/abstract.py`](gh-file:vllm_omni/diffusion/executor/abstract.py)
- Tests:
[`tests/diffusion/test_diffusion_engine.py`](gh-file:tests/diffusion/test_diffusion_engine.py)
@@ -1,131 +0,0 @@
# Adding Step Execution Support for Diffusion Pipelines
This guide documents vLLM-Omni's stepwise diffusion contract for model authors
and contributors implementing `step_execution=True` support for a diffusion
pipeline.
For end-user enablement, supported models, and current limitations, see
[Step Execution](../../user_guide/diffusion/step_execution.md).
This document describes the base step-execution contract only. For the
experimental batching policy layered on top of the step-wise path, see
[Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md).
## Current Support Scope
`step_execution` is **not** a generic diffusion toggle. It only works for
pipelines that implement the segmented stateful contract in
[`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py).
This page is intentionally author-facing. Treat runtime enablement
(`step_execution=True` in Python or `--step-execution` in serving) as an
opt-in user knob layered on top of the implementation contract below.
Current in-tree support:
| Pipeline | Example models | Step execution |
|----------|----------------|----------------|
| `QwenImagePipeline` | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes |
| All other diffusion pipelines | `QwenImageEditPipeline`, `QwenImageEditPlusPipeline`, `QwenImageLayeredPipeline`, GLM-Image, Wan, Flux, etc. | No |
Current engine/runtime limitations:
- Continuous batching with `max_num_seqs > 1` is experimental and documented in
[Continuous Batching for Step-Wise Diffusion](diffusion_continuous_batching.md).
Keep `max_num_seqs=1` for the conservative single-request step path.
- `cache_backend` is not supported in step mode.
- Request-mode extras such as KV transfer are not wired into step mode yet.
- Unsupported pipelines now fail early during model loading instead of failing on the first request.
## Execution Contract
Step mode is driven by four pipeline methods plus the shared mutable request
state object:
- `prepare_encode(state)`: one-time request preparation.
- `denoise_step(state)`: compute the noise prediction for the current step.
- `step_scheduler(state, noise_pred)`: mutate latents and advance step state.
- `post_decode(state)`: decode the final output after denoising is complete.
The state lives in
[`vllm_omni/diffusion/worker/utils.py`](gh-file:vllm_omni/diffusion/worker/utils.py)
as `StepRequestState`. Store request-scoped tensors there, or use
`state.extra` for model-specific fields that do not justify extending the core
dataclass.
The worker-side step loop lives in
[`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py):
1. `prepare_encode()` runs once for a new request.
2. `denoise_step()` runs every scheduler tick.
3. `step_scheduler()` mutates `state.latents` and advances `state.step_index`.
4. `post_decode()` runs exactly once after `state.denoise_completed` becomes true.
## Recommended Split
When converting an existing request-level `forward()` pipeline, keep the split
strict and mechanical:
| Request-level phase | Stepwise method | What belongs there |
|---------------------|-----------------|--------------------|
| Input validation, prompt encoding, latent init, timestep prep, per-request scheduler creation | `prepare_encode()` | Anything that should happen once per request |
| Transformer forward / noise prediction | `denoise_step()` | Pure denoise computation for the current timestep |
| `scheduler.step(...)` and `step_index += 1` | `step_scheduler()` | Only latent/state mutation for one step |
| VAE decode / postprocess | `post_decode()` | Final decode only |
Keep the stepwise path reusing the same helpers as the request-level path
whenever possible. Reimplementing the denoise loop from scratch is the easiest
way to introduce behavioral drift.
## Qwen-Image Reference
[`pipeline_qwen_image.py`](gh-file:vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py)
is the reference implementation and is split correctly for the current
contract:
- `prepare_encode()` reuses `_prepare_generation_context()` so prompt encoding,
latent init, timestep creation, CFG setup, and shape bookkeeping stay aligned
with `forward()`.
- `prepare_encode()` deep-copies `self.scheduler` **after**
`prepare_timesteps()` so request-specific scheduler state is isolated.
- `denoise_step()` reuses `_build_denoise_kwargs()` plus
`predict_noise_maybe_with_cfg()`, so sequential CFG, CFG-parallel, and
non-CFG behavior stay identical to the request-level path.
- `step_scheduler()` only calls
`scheduler_step_maybe_with_cfg(..., per_request_scheduler=state.scheduler)`
and increments `state.step_index`.
- `post_decode()` reuses `_decode_latents()`, so the final image decode matches
the normal `forward()` path.
That decomposition is the target pattern for future models.
## Rules For New Pipelines
- Do not keep request-scoped scheduler state on `self.scheduler`. Copy it into
`state.scheduler` during `prepare_encode()`.
- Do not mutate `state.step_index` inside `denoise_step()`. Only
`step_scheduler()` should advance the step.
- Do not decode partial outputs in `denoise_step()` or `step_scheduler()`.
- If the request-level pipeline has condition latents, masks, or edit-specific
tensors, store them in `state` or `state.extra`, not in global pipeline
attributes.
- Preserve CFG behavior by sharing the same helper path used by `forward()`.
- Keep `post_decode()` equivalent to the tail of `forward()`.
## Validation Checklist
Before marking a pipeline as `supports_step_execution = True`, verify:
- Stepwise output matches request-level output for the same seed and sampling params.
- Per-request scheduler state is isolated across concurrent requests.
- Abort during denoise does not leak cached state.
- `step_index` reported by `RunnerOutput` matches the scheduler progress.
- CFG-parallel and non-CFG paths both work if the request-level pipeline supports them.
## Related Files
- Contract: [`vllm_omni/diffusion/models/interface.py`](gh-file:vllm_omni/diffusion/models/interface.py)
- State: [`vllm_omni/diffusion/worker/utils.py`](gh-file:vllm_omni/diffusion/worker/utils.py)
- Runner loop: [`vllm_omni/diffusion/worker/diffusion_model_runner.py`](gh-file:vllm_omni/diffusion/worker/diffusion_model_runner.py)
- Scheduler transport: [`vllm_omni/diffusion/sched/interface.py`](gh-file:vllm_omni/diffusion/sched/interface.py)
- Reference pipeline: [`vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py`](gh-file:vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py)
@@ -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

+54 -8
View File
@@ -1,6 +1,8 @@
# Design Documents
This section contains design documents and architecture specifications for vLLM-Omni.
This section contains design documents and architecture specifications for
vLLM-Omni. The sidebar groups documents by the system concern they describe,
while the source paths remain stable so existing links continue to work.
## Architecture Documents
@@ -8,19 +10,63 @@ This section contains design documents and architecture specifications for vLLM-
## Feature Design Documents
- [Disaggregated Inference](feature/disaggregated_inference.md)
- [Ray-based Execution](feature/ray_based_execution.md)
- [Async Omni Output Materialization](feature/omni_async_output_materialization.md)
- [Adding Step Execution Support for Diffusion Pipelines](feature/diffusion_step_execution.md)
- [Request-Level Batching for Diffusion](feature/diffusion_request_level_batching.md)
- [Continuous Batching for Step-Wise Diffusion](feature/diffusion_continuous_batching.md)
### Runtime and stage execution
## Infrastructure Design Documents
- [Disaggregated Inference](feature/disaggregated_inference.md)
- [Async Chunk](feature/async_chunk.md)
- [Async Diffusion Output](feature/async_diffusion_output.md)
- [Async Omni Output Materialization](feature/omni_async_output_materialization.md)
- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md)
### Communication
#### OmniConnector implementations
- [Mooncake Store Connector](feature/omni_connectors/mooncake_store_connector.md)
- [Mooncake Transfer Engine Connector](feature/omni_connectors/mooncake_transfer_engine_connector.md)
- [Mori Transfer Engine Connector](feature/omni_connectors/mori_transfer_engine_connector.md)
- [Shared Memory Connector](feature/omni_connectors/shared_memory_connector.md)
- [Yuanrong Store Connector](feature/omni_connectors/yuanrong_connector.md)
- [Yuanrong Transfer Engine Connector](feature/omni_connectors/yuanrong_transfer_engine_connector.md)
### Diffusion acceleration
#### Parallelism
- [CFG-Parallel](feature/cfg_parallel.md)
- [Expert Parallel](feature/expert_parallel.md)
- [Hybrid Sharded Data Parallel (HSDP)](feature/hsdp.md)
- [Pipeline Parallel](feature/pipeline_parallel.md)
- [Sequence Parallel](feature/sequence_parallel.md)
- [Tensor Parallel](feature/tensor_parallel.md)
- [VAE Patch Parallelism](feature/vae_parallel.md)
#### Attention Backends
- [Skip-Softmax](feature/skip_softmax.md)
#### Quantization
- [Quantization](feature/quantization.md)
- [Cache-DiT](feature/cache_dit.md)
- [TeaCache](feature/teacache.md)
- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.md)
- [Distributed Layerwise Offload](feature/distributed_layerwise_offload.md)
## Infrastructure and Performance
- [Prometheus Metrics](metrics.md)
- [Speech Generation Performance Optimizations](qwen3_omni_tts_performance_optimization.md)
## Module Design Documents
### Runtime Modules
- [AR Module](module/ar_module.md)
- [DIT Module](module/dit_module.md)
### Orchestration
- [Entrypoint Module](module/entrypoint_module.md)
- [AsyncOmni Architecture (Qwen3-Omni Example)](module/async_omni_architecture.md)
+2 -3
View File
@@ -252,8 +252,7 @@ while True:
batch of compatible independent requests for one full pipeline forward when
the pipeline supports request-level batching; the step-wise path can also
admit or remove compatible requests between denoise steps. See
[Request-Level Batching](../feature/diffusion_request_level_batching.md) and
[Continuous Batching for Step-Wise Diffusion](../feature/diffusion_continuous_batching.md).
[Diffusion Continuous Batching](../feature/diffusion_continuous_batching.md).
---
@@ -540,7 +539,7 @@ 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.
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).
+2 -3
View File
@@ -87,9 +87,8 @@ if __name__ == "__main__":
!!! info
For diffusion request-level batching controls such as `max_num_seqs` and
`request_batch_max_wait_ms`, see
[Request-Level Batching](../user_guide/diffusion/request_batching.md).
For diffusion request batching, step execution, and streaming controls, see
[Diffusion Execution Modes](../user_guide/diffusion/execution_modes.md).
For more usages, please refer to [offline inference](../user_guide/examples/offline_inference/qwen2_5_omni.md)
@@ -14,7 +14,7 @@ The full set of backends and their platform defaults is in the **Backend Options
| 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. |
| `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`. |
@@ -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. |
| `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. |
| `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
@@ -69,8 +70,9 @@ vllm-omni serve <model> \
--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
one is `TRTLLM_ATTN`'s Skip-Softmax (see [below](#trtllm_attn-backend-and-skip-softmax)):
A backend that needs configuration exposes it as a typed field on the spec:
`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
--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:
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)
3. `FLASHINFER_ATTN` — when `flashinfer` is installed but cuDNN < 9.5
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` 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.
@@ -204,11 +206,80 @@ AttentionConfig(
)
```
**Requirements.** Needs FlashInfer ≥ 0.6.16rc1. Kernel
availability is arch-dependent: `fp8_e4m3` QK has kernels on both **SM100** (B200) and **SM103**
(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)
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
Distributed layerwise offloading extends single-GPU layerwise offloading to
multi-device deployments. Each DP rank stores only **1/dp_size** of the model
weights in host memory; full layer weights are reconstructed at runtime via
**AllGather** on a dedicated communication stream, overlapped with computation
via a fixed double-buffer scheme.
multi-device deployments. In the default mode, each rank stores only
**1/dp_size** of the model weights in host memory; full layer weights are
reconstructed at runtime via **AllGather** on a dedicated communication stream,
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:**
- **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 \
--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 \
--enable-distributed-layerwise-offload \
--data-parallel-size 4 \
@@ -247,9 +252,9 @@ m = Omni(
| Flag | Description | Default |
|------|-------------|---------|
| `--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-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)
@@ -303,9 +308,15 @@ request while AllGather synchronizes only weight shards (request-independent).
### Limitations
- Online quantization (FP8) is incompatible with mmap loading — falls back to
regular `load_weights()` automatically
- Tensor Parallel is not supported (DLO uses DP-based sharding)
- HSDP + AllGather is rejected (would double-shard weights)
regular loading only when the model is outside the DLO+AllGather mmap path;
online quantization with DLO+AllGather is rejected. Use
`--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
**Module Discovery**
@@ -338,7 +349,7 @@ OffloadBackend (base class)
├── LayerWiseOffloadBackend → uses LayerwiseOffloadHook
│ (single-GPU, full weights on host)
└── 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
@@ -369,4 +380,10 @@ reasoner/generator components inside the model forward pass.
**Notes:**
- 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
- 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.
@@ -0,0 +1,252 @@
# Diffusion Execution Modes
vLLM-Omni supports complete-request execution and step-wise diffusion
execution. Both modes use the same asynchronous engine output stream; the
configuration controls how work is scheduled and whether intermediate outputs
are exposed.
## Choose a Mode
| Goal | CLI configuration |
|---|---|
| Serial request execution | `--max-num-seqs 1` |
| Fused request-level batching | `--max-num-seqs N` |
| Single-request step execution | `--step-execution --max-num-seqs 1` |
| Step-wise continuous batching | `--step-execution --max-num-seqs N` |
| Chunked diffusion output | `--diffusion-streaming-output` |
`N` must be greater than `1` to allow batching. The selected pipeline must
support request-level batching or batched-step execution as appropriate;
single-request step support alone is not sufficient for step-wise continuous
batching.
## Request Execution
Request execution is the default when `--step-execution` is omitted. The
pipeline performs a complete `forward()` for each scheduler wave.
### Serial Requests
Use the conservative serial path with any request-mode pipeline:
```bash
vllm serve MODEL --omni \
--port 8091 \
--max-num-seqs 1
```
### Request-Level Batching
Set `max_num_seqs` above one to combine compatible independent requests into a
single pipeline forward:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4
```
For bursty traffic, add a small admission window:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4 \
--request-batch-max-wait-ms 20
```
`--request-batch-max-wait-ms 0` is the default. A nonzero value can improve
batch formation but adds up to that much latency before a new scheduler wave.
It has no effect in step mode.
Each prompt remains a separate logical request. Do not submit a top-level list
as one packed prompt. Concurrent serving requests are batched internally when
their shapes, guidance settings, output counts, and LoRA settings are
compatible.
Only pipelines that declare request-batch support accept
`max_num_seqs>1` in request mode. Unsupported pipelines fail during engine
initialization; use `max_num_seqs=1` instead.
## Step Execution
Step execution lets the scheduler advance and abort requests between denoise
steps:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--step-execution \
--max-num-seqs 1
```
Set a larger capacity to allow compatible requests to share denoise waves:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--step-execution \
--max-num-seqs 8
```
Step continuous batching is experimental. Start with `max_num_seqs=1` when
validating a model or debugging correctness, then increase it for
multi-request throughput.
Step execution is capability-based, not a generic switch for every diffusion
model. Qwen-Image supports step-wise continuous batching. HunyuanImage3 also
supports it, but only when its resolved self-attention backend is `TORCH_SDPA`;
set `DIFFUSION_ATTENTION_BACKEND=TORCH_SDPA` or configure
`diffusion_attention_config.default.backend=TORCH_SDPA` before using
`--max-num-seqs >1`. See the
[HunyuanImage-3.0 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Tencent/HunyuanImage-3.0-Instruct.md)
for its validated configuration. Helios supports single-request step execution only: use
`--step-execution --max-num-seqs 1` for Helios. Consult the selected pipeline's
documentation and source for the latest support status.
## Streaming Output
Use `--diffusion-streaming-output` for a pipeline that can produce intermediate
diffusion outputs:
```bash
vllm serve BestWishYsh/Helios-Distilled --omni \
--port 8000 \
--diffusion-streaming-output
```
Streaming output requires step execution. If
`--diffusion-streaming-output` is set without `--step-execution`, the engine
enables step execution automatically. Model initialization fails if the
pipeline does not implement step execution.
Chunk-capable pipelines emit intermediate and final outputs through the same
request stream. Final-only step pipelines emit only the final result even when
streaming output is enabled.
The normal non-streaming serving path uses the same internal stream but drains
it and returns only the final output. Users do not need to enable
`--diffusion-streaming-output` to benefit from the unified engine lifecycle.
## Send Requests
Execution-mode flags configure the server. They do not change the
OpenAI-compatible client request format. For example, after starting any of
the non-streaming configurations above:
```bash
curl -X POST http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"prompt": "a cat sitting on a windowsill",
"size": "1024x1024",
"num_inference_steps": 50,
"seed": 42
}' | jq -r '.data[0].b64_json' | base64 -d > cat.png
```
To create a request-level batch, send multiple independent requests
concurrently. Do not put several prompts into one `prompt` field:
```bash
for prompt in "a red fox" "a blue bird" "a green frog"; do
curl -s -X POST http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d "{\"prompt\":\"${prompt}\",\"size\":\"1024x1024\",\"seed\":42}" \
> /dev/null &
done
wait
```
The scheduler may batch these requests when their sampling parameters are
compatible. See the
[Image Generation API](../../serving/image_generation_api.md) for response
formats and additional client examples. Streaming models can use
model-specific streaming endpoints documented by their serving guide.
## Python API
The Python arguments mirror the CLI flags:
```python
from vllm_omni import Omni
omni = Omni(
model="Qwen/Qwen-Image",
max_num_seqs=4,
request_batch_max_wait_ms=20.0,
)
outputs = omni.generate(
[
"a cup of coffee on a table",
"a toy dinosaur on a sandy beach",
"a fox waking up in bed and yawning",
]
)
```
Each list item passed to `Omni.generate()` becomes an independent logical
request that the scheduler may batch with compatible items.
For step execution:
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
step_execution=True,
max_num_seqs=1,
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(num_inference_steps=50),
)
```
## Stage Configuration
Use the equivalent engine arguments in a deployment YAML:
```yaml
stage_args:
- stage_id: 0
stage_type: diffusion
engine_args:
step_execution: false
max_num_seqs: 4
request_batch_max_wait_ms: 20
```
For step execution, set `step_execution: true` and remove
`request_batch_max_wait_ms`.
## CLI Reference
| Flag | Default | Effect |
|---|---:|---|
| `--step-execution` | disabled | Select step-wise scheduling |
| `--max-num-seqs` | `1` for diffusion stages | Set request- or step-scheduler capacity |
| `--request-batch-max-wait-ms` | `0` | Wait for burst coalescing in request mode |
| `--diffusion-streaming-output` | disabled | Expose supported intermediate diffusion outputs and require step execution |
## Limitations and Troubleshooting
- Requests batch only when their compatibility-sensitive parameters match.
- Different LoRA adapters or scales run in separate batches.
- FIFO scheduling can cause an incompatible request to block later compatible
requests.
- All diffusion cache backends are unsupported in step mode. KV transfer is
supported for newly admitted step requests; some other request-mode extras
remain unsupported.
- If request-mode startup reports that the pipeline does not support batching,
use `--max-num-seqs 1`.
- If step-mode startup mentions `prepare_encode()`, `denoise_step()`,
`step_scheduler()`, or `post_decode()`, the pipeline does not implement the
required step contract.
For implementation details and model-author guidance, see
[Diffusion Continuous Batching](../../design/feature/diffusion_continuous_batching.md).
@@ -1,119 +0,0 @@
# Request-Level Batching
Request-level batching lets diffusion serving combine multiple compatible
logical requests into one pipeline `forward()` call. Each prompt remains a
separate request with its own `request_id`, sampling parameters, seed, output,
error, and abort state. The scheduler decides which requests can run together.
!!! warning "Prompt List Semantics"
Diffusion request-level batching does not support a top-level packed
list-prompt request. Submit multiple prompts as independent requests and let
the scheduler batch compatible in-flight requests. Multimodal payloads stay
inside a single prompt dict, for example
`{"prompt": "...", "multi_modal_data": {"image": image}}`.
## Enablement
Request-level execution is the default diffusion request mode when
`step_execution=False`. `max_num_seqs` controls the maximum number of compatible
requests in one scheduler wave:
- `max_num_seqs=1` is the serial request path.
- `max_num_seqs>1` allows fused request-level batching for pipelines that
declare request-batch support.
Increase `max_num_seqs` above `1` to allow the request scheduler to keep more
than one compatible request active:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4
```
For bursty online traffic, you can also set a small admission wait window. This
lets the engine wait briefly before the first `schedule()` of a new wave so
nearby compatible requests can arrive and share the same fused forward pass:
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--max-num-seqs 4 \
--request-batch-max-wait-ms 20
```
`--request-batch-max-wait-ms 0` is the default and disables admission waiting,
so there is no added wait latency.
For deploy YAMLs, configure the diffusion stage engine args:
```yaml
stage_args:
- stage_id: 0
stage_type: diffusion
engine_args:
max_num_seqs: 4
request_batch_max_wait_ms: 20
```
## Compatibility
Only pipelines that declare request-batch support can use the fused
multi-request `pipeline.forward(batch)` path. Pipelines that do not support this
contract can still use request-level execution with `max_num_seqs=1`, which is
the serial request path. Setting `max_num_seqs>1` for a pipeline without
request-batch support fails early during engine initialization.
The scheduler batches only compatible requests. Compatibility is based on
shape-sensitive and guidance-sensitive sampling fields, including resolution,
frame count, CFG settings, output count, and LoRA identity. Requests with
different LoRA adapters or scales are kept in separate batches so the worker
activates one adapter per batch.
Request-level batching applies when `step_execution=False`. For the separate
step-wise runtime, see [Step Execution](step_execution.md).
## Tuning
- `max_num_seqs` caps the number of active compatible requests in one scheduler
wave. `1` means serial request execution; values above `1` enable fused
request-level batching when the pipeline supports it.
- `request_batch_max_wait_ms` is an upper bound on extra admission wait before a
new wave starts. Keep it small for latency-sensitive serving; values such as
`10` to `50` ms are a practical starting range for bursty HTTP ingress.
- `0` disables admission waiting and preserves the lowest first-request latency.
- FIFO ordering is conservative: an incompatible request at the front of the
waiting queue can block later compatible requests from joining the current
batch.
## Python API
When constructing `Omni`, pass the same engine arguments:
```python
from vllm_omni.entrypoints.omni import Omni
omni = Omni(
model="Qwen/Qwen-Image",
max_num_seqs=4,
request_batch_max_wait_ms=20.0,
)
outputs = omni.generate(
[
"a cup of coffee on a table",
"a toy dinosaur on a sandy beach",
"a fox waking up in bed and yawning",
]
)
```
`Omni.generate([...])` submits each list item as its own logical diffusion
request. The runtime may batch those requests internally when their sampling
parameters are compatible.
## For Contributors
For implementation details and model-author guidance, see
[Request-Level Batching for Diffusion](../../design/feature/diffusion_request_level_batching.md).
-100
View File
@@ -1,100 +0,0 @@
# Step Execution
Step execution is an opt-in diffusion execution mode enabled with
`step_execution=True` when constructing `Omni`.
It is not a generic diffusion toggle for every pipeline. Only pipelines that
implement the stepwise contract support it today.
## Quick Start
### Python API
```python
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
omni = Omni(
model="Qwen/Qwen-Image",
step_execution=True,
)
outputs = omni.generate(
"A cat sitting on a windowsill",
OmniDiffusionSamplingParams(
num_inference_steps=50,
),
)
```
### Serving
```bash
vllm serve Qwen/Qwen-Image --omni \
--port 8091 \
--step-execution \
--max-num-seqs 8
```
For serving, `--step-execution` enables the step-wise runtime. Continuous
batching only becomes relevant when `--max-num-seqs > 1`.
`max_num_seqs` controls the step scheduler capacity:
- `max_num_seqs=1` runs one request through the step-wise state machine.
- `max_num_seqs>1` allows compatible active requests to share step waves.
## Supported Pipelines
| Pipeline | Example models | Step execution |
|----------|----------------|----------------|
| `QwenImagePipeline` | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-2512` | Yes |
| All other diffusion pipelines | `QwenImageEditPipeline`, `QwenImageEditPlusPipeline`, `QwenImageLayeredPipeline`, GLM-Image, Wan, Flux, etc. | No |
!!! warning "Experimental continuous batching"
When `--step-execution` is enabled and `max_num_seqs > 1` is configured,
the step-wise path can batch
compatible requests together. This is experimental. Requests with
incompatible sampling parameters are intentionally kept in separate batches,
and `max_num_seqs=1` remains the conservative single-request step path.
## Current Limitations
- Continuous batching under `step_execution` is experimental and only batches
compatible requests.
- `cache_backend` is not supported together with step execution.
- Unsupported pipelines fail early during model loading.
- Request-mode extras such as KV transfer are not wired into step mode yet.
- LoRA is supported in step mode, but each batch must use a single adapter:
requests with different `lora_request` or `lora_scale` are scheduled into
separate batches.
## When To Use It
Use step execution only when you specifically need the pipeline to run through
its stepwise request state machine. For normal diffusion inference, leave it
disabled unless your workflow depends on this mode.
For Qwen-Image online serving, the usual progression is:
- start with `--step-execution --max-num-seqs 1` if you only need the step-wise path
- increase `--max-num-seqs` after that if you want the experimental compatible-request batching behavior
If you are looking for general diffusion speedups, see
[Diffusion Features Overview](../diffusion_features.md).
## Troubleshooting
If model loading fails with a message mentioning `prepare_encode()`,
`denoise_step()`, `step_scheduler()`, and `post_decode()`, the selected
pipeline does not support step execution.
## For Model Authors
If you want to add step execution support to a new diffusion pipeline, see the
implementation guide:
[Diffusion Step Execution Design](../../design/feature/diffusion_step_execution.md).
If you also want that pipeline to participate in the experimental batched
step-wise path, see:
[Continuous Batching for Step-Wise Diffusion](../../design/feature/diffusion_continuous_batching.md).
+11 -12
View File
@@ -59,7 +59,7 @@ Memory optimization methods help reduce GPU memory usage, enabling inference on
| 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 |
| **[VAE Parallelism](diffusion/parallelism/vae_parallelism.md)** | Distributes VAE decode work across GPUs | High-resolution generation with reduced VAE memory peak |
@@ -80,12 +80,11 @@ denoise steps.
| Method | Description | Best For |
|--------|-------------|----------|
| **[Request-Level Batching](diffusion/request_batching.md)** | Scheduler batches compatible independent diffusion requests into one pipeline forward pass | Bursty online serving and multi-request throughput |
| **[Step Execution](diffusion/step_execution.md)** | Per-step denoise execution with mid-request abort support | Request cancellation between denoise steps, fine-grained execution control |
| **[Diffusion Execution Modes](diffusion/execution_modes.md)** | Configures serial requests, request batching, step execution, continuous batching, and streaming output | Matching latency, throughput, cancellation, and output-delivery requirements |
**Note:** Request-level batching is available for pipelines that declare the
request-batch forward contract. Step execution is currently supported by
QwenImagePipeline only. See [Supported Models](#supported-models) for details.
**Note:** Request-level batching and step execution are capability-based.
Consult the execution guide and the selected pipeline's documentation for
current support.
### Quantization Methods
@@ -105,8 +104,8 @@ The following tables show which models support each feature:
> Notes:
> 1. CPU Offload has two methods: Module-wise (default for models with DiT + text encoder) and Layerwise. The tables below show **Layerwise support** only. Split models like Cosmos3 (no separate text encoder) swap their reasoner/generator components for module-wise offload; see the [CPU Offload Guide](diffusion/cpu_offload_diffusion.md).
> 2. The **💾Quantization** column is collapsed for readability. See [Quantization Overview](quantization/overview.md) for per-method and per-model support details.
> 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](quantization/overview.md) for per-method and per-model support details.
### ImageGen
@@ -206,7 +205,7 @@ The following tables show which models support each feature:
3. CPU Offloading (Layerwise) and CPU Offloading (Module-wise) are not compatible.
4. CPU Offloading (Layerwise) supports single-card for now.
5. Using FP8-Quant as an example of qunatization methods.
6. Step Execution is not compatible with cache backends (TeaCache, Cache-DiT). LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches).
6. Step Execution is not compatible with any diffusion cache backend. LoRA is supported, but each scheduled batch must use a single adapter (requests with different `lora_request` or `lora_scale` are kept in separate batches).
## Multi-Thread Weight Loading
@@ -275,9 +274,9 @@ Measured on NVIDIA H800:
**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
- **[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:**
@@ -286,7 +285,7 @@ Measured on NVIDIA H800:
**Execution Modes:**
- **[Step Execution Guide](diffusion/step_execution.md)** - Per-step denoise execution with mid-request abort support
- **[Diffusion Execution Modes](diffusion/execution_modes.md)** - Configure request batching, step execution, continuous batching, and streaming output
**Startup Optimization:**
@@ -184,7 +184,7 @@ if __name__ == "__main__":
!!! info
For diffusion request-level batching controls such as `max_num_seqs`, see
[Request-Level Batching](../../diffusion/request_batching.md).
[Diffusion Execution Modes](../../diffusion/execution_modes.md).
### Negative Prompts
+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
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
| Mode | Guide | Description | Methods |
@@ -260,7 +260,7 @@ if __name__ == "__main__":
For diffusion pipelines, the input list is sliced into single-item requests
before feeding into the diffusion pipeline. For request-level batching
controls such as `max_num_seqs`, see
[Request-Level Batching](../../../docs/user_guide/diffusion/request_batching.md).
[Diffusion Execution Modes](../../../docs/user_guide/diffusion/execution_modes.md).
### Negative Prompts
+8
View File
@@ -71,6 +71,14 @@ plugins:
- search
- autorefs
- awesome-nav
- redirects:
redirect_maps:
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/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_step_execution.md: design/feature/diffusion_continuous_batching.md
- glightbox
- git-revision-date-localized:
# exclude files
+11 -7
View File
@@ -108,6 +108,7 @@ docs = [
"mkdocstrings-python",
"mkdocs-gen-files",
"mkdocs-awesome-nav",
"mkdocs-redirects",
"mkdocs-glightbox",
"mkdocs-git-revision-date-localized-plugin",
"mkdocs-minify-plugin",
@@ -249,13 +250,16 @@ markers = [
"npu: Tests that run on NPU/Ascend (auto-added)",
"musa: Tests that run on MUSA/Moore Threads (auto-added)",
# specified computation resources marks (auto-added)
"H100: Tests that require H100 GPU",
"L4: Tests that require L4 GPU",
"MI325: Tests that require MI325 GPU (AMD/ROCm)",
"B60: Tests that require Intel Arc Pro B60 XPU",
"S5000: Tests that require S5000 GPU (Moore Threads/MUSA)",
"A2: Tests that require A2 NPU",
"A3: Tests that require A3 NPU",
# Tag with [hardware-resource] for tests.helpers.mark.get_hardware_mark_list().
"H100: [hardware-resource] Tests that require H100 GPU",
"H200: [hardware-resource] Tests that require H200 GPU",
"L4: [hardware-resource] Tests that require L4 GPU",
"B200: [hardware-resource] Tests that require B200 GPU",
"MI325: [hardware-resource] Tests that require MI325 GPU (AMD/ROCm)",
"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_rocm: Tests that require multi cards on ROCm platform",
"distributed_xpu: Tests that require multi cards on XPU platform",
+1 -1
View File
@@ -332,6 +332,6 @@ bundled offline CLI do not currently expose `sigmas`.
- <https://huggingface.co/Lightricks/LTX-2.3>
- <https://huggingface.co/diffusers/LTX-2.3-Diffusers>
- [Online video generation](../../docs/user_guide/examples/online_serving/text_to_video.md)
- [Request batching](../../docs/user_guide/diffusion/request_batching.md)
- [Diffusion execution modes](../../docs/user_guide/diffusion/execution_modes.md)
- [T2V offline example](../../examples/offline_inference/text_to_video/text_to_video.md)
- [I2V offline example](../../examples/offline_inference/image_to_video/README.md)
+61
View File
@@ -0,0 +1,61 @@
# MiniMax-H3 on RTX 5090
This recipe uses BF16 weights, tiled VAE decode, tensor parallelism where a
second GPU is available, and distributed layerwise offload (DLO). It is a
memory-first serving configuration; lower resident counts reduce HBM use and
increase CPU-to-GPU transfer time.
## Capacity requirements
| Resource | One RTX 5090 | Two RTX 5090s |
| --- | ---: | ---: |
| GPU HBM | 32 GiB | 32 GiB per GPU |
| Checkpoint storage | 135 GiB per partition | 135 GiB per partition |
| Available system RAM | 200 GiB minimum | 200 GiB minimum |
| Recommended system RAM | 384 GiB | 384 GiB |
`FL2VA` and `Ref2VA` are separate 135 GiB checkpoint partitions. Start one
server at a time. DLO keeps rank-local weights in pinned host memory; increasing
`--dlo-resident-layers` improves latency but does **not** reduce host RAM in the
current implementation because resident layers retain pinned CPU master copies.
> **Modular H3:** after #5720 lands, preserve this recipe's one-partition
> behavior with `--task-type fl2va` or `--task-type ref2va`.
## One RTX 5090: 1344x768, 5 seconds
Use 12 resident DiT layers. A 50-step B300 allocation test with this exact
single-rank topology peaked at 26.50 GiB; re-measure peak HBM on the target
card before increasing the resident count.
```bash
CUDA_VISIBLE_DEVICES=0 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 1 --tensor-parallel-size 1 --text-encoder-tp-size 1 \
--usp 1 --ring 1 --vae-patch-parallel-size 1 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 12 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
## Two RTX 5090s: 1344x768, 5 seconds
Use TP2 and 20 resident DiT layers. The two-rank B300 capacity run peaked at
27,726 MiB per rank for this shape and 50 steps. This is a memory/correctness
proxy, not a consumer-GPU latency claim.
```bash
CUDA_VISIBLE_DEVICES=0,1 vllm serve /path/to/MiniMax-H3/FL2VA \
--omni --trust-remote-code --host 0.0.0.0 --port 8000 \
--num-gpus 2 --tensor-parallel-size 2 --text-encoder-tp-size 2 \
--usp 1 --ring 1 --vae-patch-parallel-size 2 \
--vae-parallel-mode tile --vae-use-tiling \
--enable-distributed-layerwise-offload --dlo-no-use-allgather \
--dlo-resident-layers 20 --enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
For Ref2VA, stop the FL2VA server and restart the same command with
`/path/to/MiniMax-H3/Ref2VA`. Ref2VA reference video count and prompt length
can increase activation memory; begin with one request at a time.
+146
View File
@@ -0,0 +1,146 @@
# MiniMax H3 on Moore Threads MUSA
> Joint video and audio generation on Moore Threads GPUs
## Summary
- Vendor: MiniMaxAI
- Model: [`MiniMaxAI/MiniMax-H3`](https://huggingface.co/MiniMaxAI/MiniMax-H3)
- Tasks: T2VA, FL2VA, and Ref2VA
- Mode: OpenAI-compatible `/v1/videos` HTTP serving
- Hardware: 4x Moore Threads MTT S5000 for the validated Ref2VA profile
- Maintainer: Community
This recipe adapts [MiniMax-H3.md](MiniMax-H3.md) for MUSA environments.
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
`ref2va`. This avoids loading both DiTs.
## Prerequisites
### Checkpoint
Download the partition required by the selected startup task. The commands below
run a single-task server; do not use the modular default (combined) mode on MUSA
until its two-DiT memory and performance profile is validated.
For T2VA and FL2VA:
```bash
python -m pip install modelscope
export MODEL_ROOT=/path/to/MiniMax-H3
modelscope download MiniMax/MiniMax-H3 \
--local_dir "${MODEL_ROOT}" \
--max-workers 16 \
--include 'FL2VA/**'
```
For Ref2VA, download `Ref2VA/**` instead.
### Environment
Install a compatible PyTorch/torch-musa, torchada, MATE, and vLLM-MUSA
stack before installing vLLM-Omni. Importing vLLM-Omni should report that the
`musa` vLLM platform plugin is active.
Install vLLM-Omni from a checkout containing MiniMax H3 support:
```bash
python -m pip install -e .
```
MiniMax H3 reference inputs and MP4 output require `soundfile`, `ffmpeg`, and
`ffprobe`. The Python dependency is installed by vLLM-Omni; install the two
executables with the operating system package manager and verify them:
```bash
python -c 'import soundfile; print(soundfile.__version__)'
ffmpeg -version
ffprobe -version
```
When torchaudio cannot load TorchCodec, vLLM-Omni automatically falls back to
soundfile. Formats that libsndfile cannot read are demuxed through ffmpeg.
## Start a server
Use the partition path and set `--task-type` explicitly: `fl2va` for T2VA/FL2VA
or `ref2va` for Ref2VA.
The validated Ref2VA configuration uses tensor parallelism across four MTT S5000 GPUs and offloads inactive model components to CPU:
```bash
export MODEL="${MODEL_ROOT}/Ref2VA"
export PORT=8091
MUSA_VISIBLE_DEVICES=0,1,2,3 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--num-gpus 4 \
--task-type ref2va \
--tensor-parallel-size 4 \
--vae-patch-parallel-size 4 \
--enable-cpu-offload \
--vae-use-tiling \
--diffusion-attention-backend FLASH_ATTN
```
On MUSA, `FLASH_ATTN` selects the MATE/FlashAttention-3 implementation. Do
not install the CUDA-only `fa4` optional dependency.
For request examples and parameter definitions, see
[MiniMax-H3.md](MiniMax-H3.md#http-api-examples).
## MUSA validation
The following weight-independent gates have been validated on one MTT S5000:
- packed variable-length attention selects MATE FlashAttention-3 and returns
finite BF16 output;
- MiniMax H3 RoPE casts CPU FP64 position metadata to FP32 before device
arithmetic and returns finite output;
- the video-VAE seeded RNG context is deterministic on MUSA and restores both
CPU and MUSA RNG states;
- the soundfile fallback preserves stereo channel layout, FP32 samples, and
the native sample rate when torchaudio/TorchCodec is unavailable.
Ref2VA image+audio generation was also validated end to end on one MTT S5000
with CPU offload and VAE tiling. The smoke used a 448x256 reference image, a
four-second 32 kHz stereo audio reference, two denoising steps, and seed 42.
The synchronous HTTP request completed in 50.6 seconds and returned an MP4
with the following decoded properties:
- 107 video frames at 448x256 and 24 FPS;
- non-static video (`pixel_std=13.07`, `temporal_delta=0.49`);
- finite stereo audio at 32 kHz (`audio_rms=0.0458`).
Official Ref2VA video-reference serving was validated with the expanded
MiniMax prompt at 1344x768, 5 seconds, seed 0, and 50 steps. A true TP4 run on
four MTT S5000 GPUs returned HTTP 200 in 638.6 seconds and produced 124 H.264
frames at 24 FPS with AAC stereo audio at 32 kHz.
Keep `--vae-use-tiling` enabled for this serving profile.
## Known limitations
- Keep Ring Attention at degree 1. The current Ring path does not preserve
MiniMax H3 packed padding boundaries.
- H3 is CFG-distilled, so `--cfg-parallel-size` must remain 1.
- This MUSA recipe uses an explicitly selected single task partition; combined
serving loads both DiTs.
- H3 currently executes one generation request per diffusion batch.
- FP8 quantization has not been enabled for MiniMax H3.
- MP3, M4A, MP4, and reference-video audio fallback requires `ffmpeg` on
`PATH`; WAV inputs can be read directly through soundfile.
## Additional resources
- [MiniMax-H3.md](MiniMax-H3.md) — full model and API guide
- [MiniMax-H3-NPU.md](MiniMax-H3-NPU.md) — Ascend NPU deployment guide
- [Supported models](../../docs/models/supported_models.md)
- [Video API](../../docs/serving/videos_api.md)
+41 -16
View File
@@ -26,12 +26,12 @@ environments. Differences from the GPU path:
### 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
hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
export MODEL=MiniMaxAI/MiniMax-H3
```
### Environment
@@ -50,7 +50,8 @@ uv pip install -e .
```
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
git clone https://gitcode.com/Ascend/MindIE-SD.git && cd MindIE-SD
@@ -72,24 +73,21 @@ pip install mindiesd-*.whl
## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for Ref2VA requests.
Pass the repository ID directly. The pipeline loads the two nested DiTs while
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
parallelism degree 8, text-encoder tensor parallelism degree 8, native tiled
VAE patch parallelism degree 8, and layerwise offload:
Use Ulysses sequence parallelism degree 8, text-encoder tensor parallelism
degree 8, native tiled VAE patch parallelism degree 8, and layerwise offload:
```bash
export ASCEND_RT_VISIBLE_DEVICES=0,1,2,3,4,5,6,7
export PORT=9098
export MODEL="${MODEL_ROOT}/FL2VA"
export MODEL=MiniMaxAI/MiniMax-H3
export VLLM_WORKER_MULTIPROC_METHOD=spawn
export VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800
export PYTHONDONTWRITEBYTECODE=1
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
vllm serve "${MODEL}" \
--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.
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
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
The request format is identical to the GPU recipe; see
@@ -145,7 +162,8 @@ throughput guarantee.
## 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.
- The first regional-compile request is a warmup and should not be included
in steady-state performance measurements.
@@ -154,9 +172,16 @@ throughput guarantee.
the same HTTP request contract.
- VAE patch parallelism requires size 1 or the full DiT group size and
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
- [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)
- [Video API](../../docs/serving/videos_api.md)
+232 -43
View File
@@ -12,33 +12,49 @@
- Maintainer: Community
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`)
- `Ref2VA`: up to 9 images, 3 videos, and 3 audio references in supported
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.
## Prerequisites
The checkpoint requires Hugging Face access approval. Download it and point
`MODEL_ROOT` at the directory containing the `FL2VA` and `Ref2VA`
subdirectories:
The checkpoint requires Hugging Face access approval. Authenticate once;
`vllm serve` downloads the required components automatically:
```bash
hf auth login
export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
export MODEL=MiniMaxAI/MiniMax-H3
```
Install vLLM-Omni from the checkout containing MiniMax H3 support. On
Blackwell, install the optional FlashAttention-4 dependency:
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
two-GPU RTX 5090/4090 profiles use cuDNN attention and do not need
FlashAttention-4. Install the optional dependency only for the four-GPU
B300/GB200 `FLASH_ATTN` profile:
```bash
uv venv
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]'
```
@@ -47,8 +63,29 @@ reference-video preparation and MP4 output.
## Start a server
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for either Ref2VA request.
Pass the repository ID directly. The pipeline uses `FL2VA` for model discovery
and shared components, and loads the second DiT from `Ref2VA/transformer`.
### Memory and storage requirements
Treat GPU HBM, host RAM, and checkpoint storage as separate requirements. Each
H3 checkpoint partition (`FL2VA` or `Ref2VA`) contains about **134 GiB** of
BF16 safetensors (about **135 GiB** on disk). Keeping both partitions
locally therefore needs roughly **270 GiB** of model storage. A combined
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
not make the model weights disappear. With `--dlo-no-use-allgather`, each
worker retains its standard-loader rank-local weights in host memory, including
pinned CPU buffers used for H2D streaming. Use at least **200 GiB available
system RAM** before starting the two-GPU recipe; a **384 GiB host is
recommended** to leave room for the OS, CUDA/PyTorch allocations, request
inputs, and filesystem cache. Do not run the FL2VA and Ref2VA servers at the
same time on a host sized for this minimum.
The consumer-GPU profiles below are HBM budgets only. They still require the
host-RAM budget above.
### Single GPU: accuracy and memory first
@@ -57,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.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091
CUDA_VISIBLE_DEVICES=0 \
@@ -74,26 +111,120 @@ vllm serve "${MODEL}" \
```
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
PCIe/NVLink transfer latency.
for both offloaded DiTs plus the shared components. Model-level offload keeps
the two DiTs mutually exclusive on GPU, but adds PCIe/NVLink transfer latency.
### Four GPUs: measured best-practice throughput
### Two 24/32 GB GPUs: TP2 distributed layerwise offload
The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
For two PCIe consumer GPUs, combine TP2 with distributed layerwise offload
(DLO). The standard loader first creates the rank-local TP shard. DLO keeps
that shard in pinned host memory and streams the 30 tail DiT blocks through a
shared two-buffer window without a DP AllGather. The first 20 DiT blocks are
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.
```bash
export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=14400 \
vllm serve "${MODEL}" \
--omni \
--host 0.0.0.0 \
--port "${PORT}" \
--trust-remote-code \
--task-type fl2va \
--num-gpus 2 \
--tensor-parallel-size 2 \
--usp 1 \
--ring 1 \
--text-encoder-tp-size 2 \
--vae-patch-parallel-size 2 \
--vae-parallel-mode tile \
--vae-use-tiling \
--enable-distributed-layerwise-offload \
--dlo-no-use-allgather \
--dlo-resident-layers 20 \
--enforce-eager \
--diffusion-attention-backend CUDNN_ATTN
```
Use the profile that matches the per-GPU memory capacity:
| Profile | GPUs | Starting shape | Resident DiT blocks | Attention | Execution | Status |
|---|---:|---:|---:|---|---|---|
| `rtx5090` | 2 x 32 GB | 1344x768 | 20 | cuDNN attention | eager | Target-hardware validated |
| `rtx4090` | 2 x 24 GB | 1024x576 | 12 | cuDNN attention | eager | Capacity-proxy starting point |
This topology uses all available parallel capacity: TP2 shards both the DiT
and text encoder, `--dlo-no-use-allgather` streams each rank's local TP shard
without reconstructing full blocks, and VAE patch parallelism splits tiled
decode across both GPUs. cuDNN attention is selected explicitly for the RTX
consumer path; the server stays eager to avoid an unqualified compile path.
The resident count changes placement and transfer frequency only; it does not
quantize or change the BF16/FP32 denoise math. Re-measure peak memory before
increasing it on a different request shape.
### RTX 5090 target-hardware validation
At vLLM-Omni commit `ae6577ea`, one full 50-step T2VA request completed on
2 x RTX 5090 without OOM:
| Shape | Frames | Client E2E | Sampled peak/GPU | Output validation |
|---:|---:|---:|---:|---|
| 1344x768 | 124 at 24 FPS | 8 min 38 s | approximately 22.6 GiB | H.264 video + 32 kHz stereo AAC; full `ffmpeg` decode passed |
This is a single end-to-end validation run, not a warmed multi-run latency
benchmark. The sampled `nvidia-smi` peak is also not a CUDA allocator
high-water mark. The environment used vLLM 0.26.0, vLLM-Omni
`0.26.1.dev14+gae6577ea`, and PyTorch 2.11.0+cu130. The
[run record](https://github.com/lishunyang12/vllm-omni-rankings/blob/dcd06d7e83cb069842535918c0169ee9f3f29ba0/scripts/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20260805000034_86_237.png)
captures the environment, output contract, elapsed time, and sampled peak.
Before the target run, both profiles were exercised on two B300 ranks as an
allocation and correctness proxy. At 1344x768, 124 frames, and 50 steps, the
20-layer profile peaked at 27,726 MiB per rank. At 1024x576, the 12-layer
profile peaked at 18,888 MiB per rank in a 5-step capacity run. The resident
and fully streamed placements produced identical decoded video-frame and audio
hashes for the same shape, step count, prompt, and seed. The B300 result does
not establish RTX 4090 PCIe latency; treat the 4090 profile as a conservative
starting point until it is measured on that GPU.
To run T2VA, FL2VA, image+audio Ref2VA, and two-video Ref2VA in order, validate
every MP4's H.264/AAC streams, and retain live server and GPU-memory logs:
```bash
RUN_ROOT=/path/to/run-root \
MODEL_ROOT=/path/to/MiniMax-H3 \
GPU_IDS=0,1 \
PROFILE=rtx5090 \
bash examples/offline_inference/minimax_h3/run_h3_2gpu_all_tasks.sh
```
The script selects 20 resident layers for `PROFILE=rtx5090` and 12 for
`PROFILE=rtx4090`; `DLO_RESIDENT_LAYERS=N` overrides either default.
### Four GPUs: throughput-oriented combined service
For a combined service on four high-memory GPUs, use:
- no CPU or layerwise offload;
- Ulysses sequence parallelism degree 4;
- native tiled VAE patch parallelism degree 4;
- regional `torch.compile` for the repeated DiT blocks;
- CuTe FlashAttention-4 through the `FLASH_ATTN` backend, with Ring and TP
left at 1.
- dense BF16 `TRTLLM_ATTN`, with Ring and TP left at 1.
Both DiTs remain resident in this no-offload configuration. If they do not fit,
use model-level CPU offload.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
@@ -106,19 +237,76 @@ vllm serve "${MODEL}" \
--ring 1 \
--vae-patch-parallel-size 4 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend FLASH_ATTN
--vae-use-tiling
```
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
request includes regional compilation; warm the server once before measuring
steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain
1. The H3 VAE supports its native `tile` mode, not
`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
The Qwen3-VL text encoder (~51.5 GB in BF16 for the retained 50 layers) is by
@@ -129,11 +317,10 @@ vLLM-style tensor-parallel layers and runs with distributed collectives over
its own encoder process group):
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
export MODEL=MiniMaxAI/MiniMax-H3
export PORT=8091
CUDA_VISIBLE_DEVICES=0,1,2,3 \
FLASHINFER_DISABLE_VERSION_CHECK=1 \
VLLM_WORKER_MULTIPROC_METHOD=spawn \
VLLM_OMNI_VIDEO_SYNC_TIMEOUT=1800 \
vllm serve "${MODEL}" \
@@ -147,8 +334,7 @@ vllm serve "${MODEL}" \
--text-encoder-tp-size 4 \
--vae-patch-parallel-size 4 \
--vae-parallel-mode tile \
--vae-use-tiling \
--diffusion-attention-backend FLASH_ATTN
--vae-use-tiling
```
`N` must divide the Qwen3-VL head counts (64 attention heads / 8 KV heads), so
@@ -161,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
replicated on every rank.
To serve Ref2VA, stop the FL2VA server and restart the same one- or four-GPU
command with:
```bash
export MODEL="${MODEL_ROOT}/Ref2VA"
```
No restart is needed: `task=fl2va` routes to `FL2VA/transformer`, while
`task=ref2va` routes to
`Ref2VA/transformer`. T2VA uses the FL2VA DiT.
### Online FP8 quantization
@@ -222,7 +405,7 @@ export API_URL="http://127.0.0.1:${PORT}/v1/videos/sync"
### 1. T2VA: text to video and audio
Run this request against the `FL2VA` partition:
Run this request against the combined service:
```bash
curl -sS -X POST "${API_URL}" \
@@ -240,7 +423,7 @@ curl -sS -X POST "${API_URL}" \
### 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
edge.
@@ -279,10 +462,11 @@ curl -sS -X POST "${API_URL}" \
### 3. Ref2VA: image-only, image/audio, or mixed references
Run these requests against the `Ref2VA` partition. Image-only Ref2VA omits
`audio_reference`; adding one or more audio references is optional. The typed
fields accept one object or an ordered JSON list. `audio_reference` accepts an
HTTP(S) URL or a `data:` URL.
Run these requests against the combined service or a Ref2VA-only service.
Image-only Ref2VA omits `audio_reference`; adding one or more audio references
is optional. The typed fields accept one object or an ordered JSON list.
`audio_reference` accepts an HTTP(S) URL or a `data:` URL. In one terminal,
expose the local reference assets to the serving host:
```bash
python -m http.server 8092 \
@@ -332,7 +516,7 @@ shorter, the reference soundtrack is truncated to the generated clip.
### 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
videos in form order and preserves their original soundtracks during
conditioning.
@@ -394,7 +578,7 @@ than one is requested.
| 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 |
| `fps` | `24` | H3 output FPS is fixed |
| `num_inference_steps` | `50` | Matches the reference accuracy workloads |
@@ -407,6 +591,10 @@ than one is requested.
| `start_time_seconds` | `0` | Reference-video segment start; use a list in `extra_params` for multiple videos |
| `width`, `height` | Multiples of 32 | Output aspect ratio must be between 1:4 and 4:1 |
## ComfyUI Frontend
Users can also use a ComfyUI frontend to interact with a hosted MiniMax-H3 service. The ComfyUI frontend can run in a separate environment or machine. Refer to [vLLM-Omni ComfyUI Integration](../../docs/features/comfyui.md) for details.
## Validated four-GPU evidence
The four-GPU recommendation was measured on four NVIDIA B300 GPUs with one
@@ -432,7 +620,8 @@ reduction.
## 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.
- The first regional-compile request is a warmup and should not be included in
steady-state performance measurements.
+2 -1
View File
@@ -45,7 +45,8 @@ recipes/
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
| [`inclusionAI/Ming-omni-tts.md`](./inclusionAI/Ming-omni-tts.md) | Offline + online dense Ming TTS/audio generation | 1x H100 80GB / 1x AMD MI300X (ROCm 7.2) |
| [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU |
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, first/last-keyframe FL2VA, and full mixed-reference Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
| [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 |
| [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) |
| [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU |
| [`MammothModa2/MammothModa2.md`](./MammothModa2/MammothModa2.md) | Preview and Dev text-to-image (AR → DiT); Dev text/image understanding | Preview: 1x L40S 48GB / 1x ≥40GB GPU; Dev: 1x NVIDIA GPU with sufficient cache headroom |
+5 -4
View File
@@ -164,10 +164,11 @@ curl -s http://localhost:8091/v1/chat/completions \
PR [#2495](https://github.com/vllm-project/vllm-omni/pull/2495) adds
performance CI configs for the same DiT-only settings. The CI step is
currently opt-in (gated by `RUN_HUNYUAN_IMAGE3_PERF=1`) with `soft_fail`
enabled, intended for initial data collection. Performance assertions are
skipped (`skip-performance-assertion: true`); the baseline values in the
JSON configs are reference-only and will be promoted to regression gates
once enough nightly data has been collected.
enabled, intended for initial data collection. Perf runners no longer
compare metrics against JSON baselines at assert time; the `baseline`
blocks in the configs are reference-only and are copied into result JSON
for reporting. Regression gates can be reintroduced once enough nightly
data has been collected.
The user-facing equivalent is to launch one of the CLI commands above and
generate 1024x1024 images with 50 denoising steps.
+18 -10
View File
@@ -56,10 +56,14 @@ vllm serve /path/to/Cosmos3-Super \
--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 —
no AllGather synchronization, no concurrent request requirement, but N× host memory.
Use `--dlo-no-use-allgather` when you want each rank to stream the standard
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
vllm serve /path/to/Cosmos3-Nano \
@@ -154,9 +158,9 @@ wait
| Flag | Description | Default |
|------|-------------|---------|
| `--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-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` |
## 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 (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 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 |
## Memory expectations
@@ -185,7 +189,11 @@ wait
- `--dlo-use-allgather` (default) requires all concurrent requests to have the same
`num_inference_steps` (AllGather is a collective that needs all ranks synchronized)
- Online quantization (FP8) is incompatible with mmap loading — falls back to regular
`load_weights()` automatically
- Tensor Parallel is not supported (DLO uses DP-based sharding, a different dimension)
- HSDP + AllGather is rejected (would double-shard weights)
- Online quantization (FP8) is incompatible with the DLO+AllGather mmap path;
use `--dlo-no-use-allgather` or disable online quantization
- Tensor Parallel > 1 is rejected in the DLO+AllGather mmap path. TP with
`--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
+150
View File
@@ -423,6 +423,156 @@ ffprobe -v error -show_entries stream=codec_type,nb_frames,width,height cosmos3_
once in `vllm_omni/model_extras/cosmos3.py` and forwarded through `--extra-body`;
unknown keys for the model are dropped.
## ROCm
### 1x MI350X (Online serving)
#### Environment
- OS: Ubuntu 22.04+
- Python: 3.12+
- Driver / runtime: ROCm, HIP 7.2, **gfx942 or gfx950** (validated on gfx950 / MI350X,
which exposes 252 GiB of HBM to PyTorch)
- PyTorch: 2.11.0 (ROCm build)
- vLLM version: match the repository requirements from your current checkout
- vLLM-Omni version or commit: use the commit you are deploying from
No ROCm-specific patch or flag is needed: Cosmos3 runs on the stock code path.
#### Command
The NVIDIA command works unchanged. Guardrails need the gated
`nvidia/Cosmos-1.0-Guardrail` repo, so the quick path is to disable them (you are
then responsible for license compliance):
```bash
vllm serve nvidia/Cosmos3-Nano \
--omni \
--no-guardrails \
--host 0.0.0.0 --port 8000 \
--init-timeout 1800
```
For 720p video, add `--vae-use-tiling`: it cuts peak VRAM by ~68% for ~13%
latency, which is the difference between needing a 120 GiB card and a 40 GiB one.
`--quantization fp8` and `--enable-layerwise-offload` are also supported; see the
Notes for the measured cost of each. For extra GPUs use `--ulysses-degree N` or
`--tensor-parallel-size N` (not validated on ROCm yet).
#### Verification
Use the same `curl` requests as the CUDA section above. The ROCm images do not
ship `ffprobe`, so the CUDA section's `ffprobe` line does not work here. `imageio`
and `pyav` are already installed and report the same thing:
```bash
python -c "from PIL import Image; im=Image.open('cosmos3_t2i.png'); print(im.size, im.mode)"
python -c "import imageio.v3 as iio; print(iio.improps('cosmos3_t2v.mp4', plugin='pyav').shape)"
```
Expect `(1024, 1024) RGB` and `(189, 720, 1280, 3)` for the requests above.
Re-running the same request with the same seed reproduces a byte-identical mp4,
so an md5 comparison across two runs also works as a smoke check.
#### Notes
- **Warm up once per output shape before timing anything.** The first generation
of a shape pays a one-time cost that is cached on disk and reused by later
processes in the same container: a ~250 s aiter attention-kernel JIT build plus
a ~390 s build of the VAE decode path *for that shape*, so warming 1024² images
does nothing for 189-frame video. At 720p / 189 frames / 35 steps this is
**540 s cold vs 161 s warm, with byte-identical output**.
- **Measured on 1x MI350X (bf16, guardrails off, warm):** T2I 1024² @ 50 steps
**~2.7 s**; T2V 1280×720 / 189 frames @ 35 steps **~161 s**, of which ~92% is
the DiT denoise loop and ~4% VAE decode, so optimization effort belongs in the
denoise loop. The optional flags act on different terms of the memory bill and
are therefore complementary rather than redundant:
| Flag | Latency | Peak reserved | Peak allocated | Acts on |
|---|---|---|---|---|
| *(none)* | 161 s | 120 GiB | 95 GiB | — |
| `--vae-use-tiling` | 183 s (+13.5%) | **38 GiB** (−68%) | **36 GiB** (−62%) | decode activations |
| `--enable-layerwise-offload` | 162 s (+0.8%) | 84 GiB (−30%) | 69 GiB (−27%) | weights |
| `--quantization fp8` | 150 s (−6.6%) | 107 GiB (−11%) | 82 GiB (−14%) | weights (online, no calibration) |
Reserved is the caching-allocator high water mark and runs ~2–25 GiB above
allocated; allocated is the figure to compare against another platform.
- **`--vae-use-tiling` is by far the biggest lever for video.** Of the 95 GiB
allocated by default only ~35 GiB is weights and fixed overhead; the remaining
**60 GiB is activations from decoding all 189 frames in one piece**, which is
why fp8 and offload cannot touch it and why tiling, which leaves under 5 GiB of
them, wins so much. `--vae-use-slicing` is a no-op for a single video request:
peak memory is bit-identical to the default.
- **`--enable-layerwise-offload` is close to free on this part:** per-block DiT
compute is long enough to hide the weight transfer, so on a card this size it
buys headroom for concurrency or higher resolution rather than the
speed-for-memory trade it is on small cards.
- **Attention backend:** the numbers above are aiter FlashAttention, not SDPA.
ROCm picks aiter when it is installed and the encoded device capability
satisfies `90 < major * 10 + minor < 100` (gfx942 and gfx950); otherwise it
falls back to `TORCH_SDPA` and says so only at debug level, which is easy to
miss. If you are reporting numbers, assert the choice instead of trusting the
default: `RocmOmniPlatform.get_diffusion_attn_backend_cls(None, 128)` returns
the resolved path.
- **Determinism is per-configuration:** same seed and same flags reproduce a
byte-identical mp4 across processes, but different configurations do not match
each other — including offload, which nominally only relocates weights, so do
not use it as a bit-exact regression baseline. Quality was not evaluated for
any configuration.
### 1x MI350X (Offline generation)
#### Environment
Same as the online serving section above.
#### Command
The standard task examples run unchanged:
```bash
# Text-to-image
python examples/offline_inference/text_to_image/text_to_image.py \
--model nvidia/Cosmos3-Nano \
--prompt "A photorealistic red sports car at golden hour, cinematic lighting." \
--negative-prompt "blurry, distorted, low quality" \
--height 1024 --width 1024 --num-inference-steps 50 --guidance-scale 7.0 \
--extra-body '{"flow_shift": 3.0, "guardrails": false}' \
--output cosmos3_t2i.png
# Text-to-video
python examples/offline_inference/text_to_video/text_to_video.py \
--model nvidia/Cosmos3-Nano \
--prompt "A robot arm is cleaning a plate in the kitchen." \
--negative-prompt "blurry, distorted, low quality" \
--height 720 --width 1280 --num-frames 189 --fps 24 \
--num-inference-steps 35 --guidance-scale 6.0 --flow-shift 10.0 \
--extra-body '{"max_sequence_length": 4096, "guardrails": false,
"use_resolution_template": false, "use_duration_template": false}' \
--output cosmos3_t2v.mp4
```
Both scripts print `Total generation time` and `Worker peak GPU memory
(reserved)`, which is where the latency and reserved figures in the Notes above
come from. Add `--vae-use-tiling`, `--quantization fp8` or
`--enable-layerwise-offload` to reproduce the other rows. The allocated column
needs `VLLM_LOGGING_LEVEL=DEBUG`, which enables a per-request line reporting
reserved, allocated and pool overhead together.
#### Verification
Same as the online serving section above.
#### Notes
- Run each command twice and read the second run; the first pays the one-time
compilation described above. If you run in a container, keep the container
alive between runs (`docker run --rm` throws the JIT cache away and you pay
the ~250 s kernel build every time).
- Latency and memory figures are identical to the online serving section — they
were taken from this path, because a single process has no client/server
timing ambiguity.
## NPU
### 1x Ascend 910B / 910C (Atlas A2 / A3) — Online serving
+99 -7
View File
@@ -39,7 +39,7 @@ from vllm_omni.config.stage_config import (
load_deploy_config,
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]
@@ -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.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.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization", 0.90)
assert omni_stage.scheduler_config.max_num_seqs == engine_args.get("max_num_seqs", 128)
assert omni_stage.cache_config.gpu_memory_utilization == engine_args.get("gpu_memory_utilization")
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.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 {}
assert omni_stage.parallel_config.tensor_parallel_size == legacy_parallel_config.get(
"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
@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():
omni_config = _from_pipeline_key("hunyuan_image3_dit")
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
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)} == {
"distributed_executor_backend",
"worker_cls",
"devices",
"num_replicas",
"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)} == {
"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",
"duplex_max_sessions",
"enable_sleep_mode",
@@ -404,6 +483,7 @@ def test_sub_config_fields_match_rfc_scopes():
"task_type",
"codec_frame_rate_hz",
"enforce_eager",
"max_cudagraph_capture_size",
"enable_flashinfer_autotune",
"compilation_config",
"enable_multithread_weight_load",
@@ -416,12 +496,16 @@ def test_sub_config_fields_match_rfc_scopes():
"tokenizer_subdir",
}
assert {f.name for f in fields(OmniStageLoadConfig)} == {
"tokenizer",
"download_dir",
"skip_tokenizer_init",
"load_format",
"tokenizer_mode",
"config_format",
"skip_mm_profiling",
}
assert {f.name for f in fields(OmniStageCacheConfig)} == {
"kv_cache_memory_bytes",
"gpu_memory_utilization",
"enable_prefix_caching",
"disable_hybrid_kv_cache_manager",
@@ -435,6 +519,8 @@ def test_sub_config_fields_match_rfc_scopes():
"async_scheduling",
}
assert {f.name for f in fields(OmniStageConnectorConfig)} == {
"async_chunk",
"omni_kv_config",
"stage_connector",
"output_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")
legacy_stage = merge_pipeline_deploy(pipeline, _load_default_deploy(pipeline))[0]
omega_stage = legacy_stage.to_omegaconf()
legacy_engine_args = build_engine_args_dict(
legacy_engine_args = build_legacy_engine_args_dict(
omega_stage,
model="/tmp/qwen3-tts",
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():
from vllm_omni.diffusion.data import OmniDiffusionConfig
classified_fields = (
omni_config_module._DIFFUSION_SHARED_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 {f.name for f in fields(OmniDiffusionConfig)} <= (
classified_fields | omni_config_module._DIFFUSION_MOVED_SHARED_FIELDS
)
assert {
"enable_prompt_embed_cache",
"enable_session_state_manager",
"prompt_embed_cache_size",
"diffusion_kv_cache_dtype",
} <= omni_config_module._DIFFUSION_ONLY_CONFIG_FIELDS
+93 -150
View File
@@ -3,15 +3,13 @@ import os
import re
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from tests.dfx.reliability.helpers import list_remote_process_pids_by_pattern, post_chat_completions_raw
from tests.helpers.mark import hardware_marks
from tests.helpers.mark import get_hardware_mark_list, hardware_marks
from tests.helpers.runtime import OmniServerParams
from tests.helpers.stage_config import modify_stage_config
from vllm_omni.platforms import current_omni_platform
@@ -119,32 +117,6 @@ def create_unique_server_pytest_params(
]
def create_benchmark_pytest_params(
benchmark_configs: list[dict[str, Any]],
server_to_benchmark_mapping: dict[str, dict],
) -> list[Any]:
"""Like :func:`create_benchmark_indices`, but wrap each index in ``pytest.param`` with JSON marks."""
marks_by_name = _marks_by_test_name(benchmark_configs)
params: list[Any] = []
seen: set[str] = set()
for config in benchmark_configs:
test_name = config["test_name"]
if test_name in seen:
continue
seen.add(test_name)
params_list = get_benchmark_params_for_server(test_name, server_to_benchmark_mapping)
id_suffixes = _unique_benchmark_param_id_suffixes(params_list)
for idx, id_suffix in enumerate(id_suffixes):
params.append(
pytest.param(
(test_name, idx),
marks=marks_by_name.get(test_name, []),
id=f"{test_name}-{id_suffix}",
)
)
return params
def create_paired_benchmark_pytest_params(
server_entries: list[tuple[Any, str]],
params_by_test_name: dict[str, list[Any]],
@@ -357,10 +329,6 @@ def supports_video_generation(model_name: str) -> bool:
return any(key in lower for key in ("wan", "video", "i2v", "t2v"))
def supports_chat_generation(model_name: str) -> bool:
return not supports_video_generation(model_name)
def parse_stage_devices(stage_config_path: str) -> str:
text = Path(stage_config_path).read_text(encoding="utf-8")
raw_devices: list[str] = re.findall(r"^\s*devices:\s*\"?([0-9,\s]+)\"?\s*$", text, flags=re.MULTILINE)
@@ -389,48 +357,6 @@ def assert_fault_exception(exc: Exception, error_keywords: tuple[str, ...]) -> N
assert any(key in text for key in error_keywords), f"unexpected error under fault injection: {exc}"
def wait_chat_request_ready(host: str, port: int, model: str, timeout_sec: int = 180) -> None:
"""Poll a minimal chat request until success."""
deadline = time.time() + timeout_sec
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": "Say hello in one short sentence."}],
"stream": False,
"modalities": ["text"],
}
)
last_error: str | None = None
while time.time() < deadline:
try:
status, body = post_chat_completions_raw(host, port, payload)
if status == 200:
return
last_error = f"http={status} body={body[:200]!r}"
except Exception as exc: # noqa: BLE001
last_error = str(exc)
time.sleep(2)
raise TimeoutError(f"runtime-teardown warmup request did not succeed within {timeout_sec}s: {last_error}")
def assert_no_extra_worker_processes(
baseline_pids: set[int],
worker_pattern: str,
timeout_sec: int = 60,
) -> None:
"""Ensure no extra worker PIDs remain after teardown."""
deadline = time.time() + timeout_sec
while time.time() < deadline:
current = set(list_remote_process_pids_by_pattern(worker_pattern))
extra = current - baseline_pids
if not extra:
return
time.sleep(2)
current = set(list_remote_process_pids_by_pattern(worker_pattern))
extra = sorted(current - baseline_pids)
assert not extra, f"orphan worker processes remain after container teardown: {extra}"
def create_test_parameter_mapping(configs: list[dict[str, Any]]) -> dict[str, dict]:
mapping = {}
for config in configs:
@@ -520,36 +446,16 @@ def _unique_benchmark_param_id_suffixes(params_list: list[dict[str, Any]]) -> li
return unique
def extract_mark_resource_label(mark_field: Any) -> str:
"""Return a filename-safe hardware label from ``mark.hardware_marks.res`` values.
Example: ``{"cuda": "H100"}`` -> ``"H100"``; multiple platforms join with ``-``.
Prefer :func:`get_runtime_resource_label` for perf result filenames so labels
reflect the machine that actually ran the benchmark.
"""
if isinstance(mark_field, list):
for item in mark_field:
if isinstance(item, dict) and "hardware_marks" in item:
return extract_mark_resource_label(item)
return "na"
if not isinstance(mark_field, dict):
return "na"
hw = mark_field.get("hardware_marks")
if not isinstance(hw, dict):
return "na"
res = hw.get("res")
if not isinstance(res, dict) or not res:
return "na"
labels = [_safe_filename_token(value) for value in res.values()]
return "-".join(labels) if labels else "na"
_KNOWN_RUNTIME_RESOURCE_TOKENS: tuple[str, ...] = (
# Device marketing-name tokens for ``get_runtime_resource_label`` only.
# Longer / more specific tokens must appear before shorter substrings
# (e.g. ``H200`` before ``H20``, ``910B4`` before ``910``).
_RUNTIME_DEVICE_ALIASES: tuple[str, ...] = (
"H100",
"H800",
"H200",
"H20",
"B200",
"GB200",
"L40S",
"L40",
"L4",
@@ -578,7 +484,7 @@ def _normalize_runtime_device_label(raw: str) -> str:
if not raw or not str(raw).strip():
return "na"
upper = str(raw).upper()
for token in _KNOWN_RUNTIME_RESOURCE_TOKENS:
for token in _RUNTIME_DEVICE_ALIASES:
if token.upper() in upper:
return _safe_filename_token(token)
compact = re.sub(r"[^a-zA-Z0-9]+", "", str(raw))
@@ -631,34 +537,41 @@ def resource_label_for_filename(resource_label: str | None) -> str:
return token
def extract_configs_resource_label(configs: list[dict[str, Any]]) -> str:
"""Return runtime hardware label for perf result filenames."""
del configs
return get_runtime_resource_label()
def is_hardware_nested_baseline(baseline: dict[str, Any]) -> bool:
"""True for ``{"H100": {"metric": ...}, "A3": {...}}`` baselines.
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,
max_concurrency: Any = None,
request_rate: Any = None,
) -> Any:
"""Pick the baseline threshold for this sweep step."""
"""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 100000
return None
if isinstance(baseline_raw, dict):
if max_concurrency is not None:
for key in (max_concurrency, str(max_concurrency)):
if key in baseline_raw:
return baseline_raw[key]
if request_rate is not None:
for key in (request_rate, str(request_rate)):
if key in baseline_raw:
return baseline_raw[key]
raise KeyError(
f"baseline dict has no key for max_concurrency={max_concurrency!r} "
f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}"
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:
@@ -669,22 +582,62 @@ def resolve_baseline_value(
return baseline_raw
def _baseline_thresholds_for_step(
baseline_data: dict[str, Any],
def resolve_baseline_for_sweep(
baseline_config: dict[str, Any] | None,
*,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> dict[str, Any]:
"""Resolve baseline config to one threshold per metric for this iteration."""
return {
metric_name: resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
"""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."
)
for metric_name, baseline_raw in baseline_data.items()
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)
}
@@ -697,17 +650,17 @@ def run_benchmark(
*,
baseline_config: dict[str, Any] | None = None,
sweep_index: int | None = None,
request_rate: Any | None = None,
max_concurrency: Any | None = None,
random_input_len: Any | None = None,
random_output_len: Any | None = None,
resource_label: str | None = None,
) -> dict[str, Any]:
"""Run one ``vllm bench serve --omni`` iteration and return parsed metrics.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the resolved per-metric thresholds
(when ``baseline_config`` is provided). If the benchmark exits without writing a result file,
``result_omni_template.json`` is used as a fallback.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` stores the
sweep-resolved baseline (when ``baseline_config`` is provided). Hardware-nested
maps keep every hardware bucket, but each metric is reduced to the value for
this concurrency / request-rate step. If the benchmark exits without writing a
result file, ``result_omni_template.json`` is used as a fallback.
"""
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
ri = _safe_filename_token(random_input_len)
@@ -774,11 +727,10 @@ def run_benchmark(
result = json.load(f)
if baseline_config:
result["baseline"] = _baseline_thresholds_for_step(
# Keep every hardware bucket; resolve list metrics to this sweep step.
result["baseline"] = resolve_baseline_for_sweep(
baseline_config,
sweep_index=sweep_index,
request_rate=request_rate,
max_concurrency=max_concurrency,
)
else:
result["baseline"] = {}
@@ -800,12 +752,3 @@ def pytest_addoption(parser: pytest.Parser) -> None:
default=None,
help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
)
parser.addoption(
"--assert-baseline",
action="store_true",
default=False,
help=(
"When set, omni/diffusion perf runners compare metrics against the baseline block in the JSON config "
"(default: off)."
),
)
@@ -21,10 +21,12 @@
"num-input-images": 0,
"enable-negative-prompt": false,
"baseline": {
"throughput_qps": 0,
"latency_mean": 0,
"peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0
"H100": {
"throughput_qps": 0,
"latency_mean": 0,
"peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0
}
}
},
"result": {
@@ -46,9 +46,11 @@
"median_audio_duration_s": 0,
"p99_audio_duration_s": 0,
"baseline": {
"mean_ttft_ms": 0,
"mean_audio_ttfp_ms": 0,
"mean_audio_rtf": 0
"H100": {
"mean_ttft_ms": 0,
"mean_audio_ttfp_ms": 0,
"mean_audio_rtf": 0
}
},
"random_input_len": 0,
"random_output_len": 0
+8 -60
View File
@@ -15,15 +15,10 @@ from tests.dfx.conftest import (
get_runtime_resource_label,
is_diffusion_perf_config,
load_benchmark_configs,
resolve_baseline_value,
run_benchmark,
)
from tests.helpers.runtime import OmniServer
# Compare metrics to each test JSON ``baseline`` block only when pytest is run with ``--assert-baseline``
# (registered in ``tests/dfx/conftest.py``; default: off). ``run_benchmark`` and ``_resolve_baseline_value`` are
# defined in the same module.
#
# Optional JSON field ``mark`` is applied as pytest marks via
# ``create_paired_omni_benchmark_pytest_params`` (e.g. ``"mark": [{"hardware_marks":
# {"res": {"cuda": "H100"}, "num_cards": 2}}, "full_model", "omni"]``).
@@ -162,36 +157,8 @@ def benchmark_params(request):
}
def assert_result(
result,
params,
num_prompt,
*,
assert_baseline: bool,
sweep_index: int | None = None,
max_concurrency: Any | None = None,
request_rate: Any | None = None,
) -> None:
def assert_result(result, num_prompt) -> None:
assert result["completed"] == num_prompt, "Request failures exist"
if not assert_baseline:
return
baseline_data = params.get("baseline", {})
for metric_name, baseline_raw in baseline_data.items():
current_value = result[metric_name]
baseline_value = resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
if "throughput" in metric_name:
if current_value <= baseline_value:
print(
f"ERROR: Throughput test results were below baseline: {metric_name}: {current_value} > {baseline_value}"
)
else:
if current_value >= baseline_value:
print(f"ERROR: Test results exceeded baseline: {metric_name}: {current_value} < {baseline_value}")
@pytest.mark.benchmark
@@ -200,7 +167,7 @@ def assert_result(
paired_benchmark_params,
indirect=["omni_server", "benchmark_params"],
)
def test_performance_benchmark(omni_server, benchmark_params, request):
def test_performance_benchmark(omni_server, benchmark_params):
test_name = benchmark_params["test_name"]
params = benchmark_params["params"]
dataset_name = params.get("dataset_name", "")
@@ -212,7 +179,6 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
print(f"Running benchmark for model: {model}")
print(f"Benchmark parameters: {benchmark_params}")
assert_baseline = request.config.getoption("--assert-baseline", default=False)
resource_label = get_runtime_resource_label()
def to_list(value, default=None):
@@ -271,7 +237,7 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
break
# QPS / request-rate sweep
for i, (qps, num_prompt) in enumerate(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)]
result = run_benchmark(
args=args,
@@ -280,24 +246,15 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
dataset_name=dataset_name,
num_prompt=num_prompt,
baseline_config=params.get("baseline"),
sweep_index=i,
request_rate=qps,
max_concurrency=None,
sweep_index=sweep_index,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
resource_label=resource_label,
)
assert_result(
result,
params,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
request_rate=qps,
)
assert_result(result, num_prompt)
# concurrency test
for i, (concurrency, num_prompt) in enumerate(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"]
result = run_benchmark(
args=args,
@@ -306,18 +263,9 @@ def test_performance_benchmark(omni_server, benchmark_params, request):
dataset_name=dataset_name,
num_prompt=num_prompt,
baseline_config=params.get("baseline"),
sweep_index=i,
request_rate=None,
max_concurrency=concurrency,
sweep_index=sweep_index,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
resource_label=resource_label,
)
assert_result(
result,
params,
num_prompt,
assert_baseline=assert_baseline,
sweep_index=i,
max_concurrency=concurrency,
)
assert_result(result, num_prompt)
+66 -103
View File
@@ -13,8 +13,6 @@ A config JSON file may be passed via --test-config-file. If omitted, every ``*.j
pytest run_diffusion_benchmark.py -m "diffusion"
pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
Optional: ``--assert-baseline`` compares metrics to the ``baseline`` block in each benchmark entry (default: off).
Optional JSON field ``mark`` is applied as pytest marks on that case via
``pytest.param`` (e.g. ``"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 1}}, "full_model", "diffusion"]``).
@@ -46,6 +44,7 @@ from tests.dfx.conftest import (
get_runtime_resource_label,
hardware_json_value,
is_diffusion_perf_config,
resolve_baseline_for_sweep,
resolve_pytest_marks,
resource_label_for_filename,
)
@@ -274,11 +273,6 @@ def _write_result_record(record: dict[str, Any]) -> Path:
return target
def _append_to_aggregated_file(record: dict[str, Any]) -> None:
"""Backward-compatible wrapper; prefer :func:`_write_result_record`."""
_write_result_record(record)
_server_lock = threading.Lock()
# ---------------------------------------------------------------------------
@@ -356,10 +350,49 @@ def _kill_process_tree(pid: int) -> None:
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
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
from huggingface_hub import snapshot_download
@@ -393,6 +426,11 @@ class DiffusionServer:
def _start_server(self) -> None:
env = os.environ.copy()
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 = [
sys.executable,
@@ -678,7 +716,7 @@ def run_benchmark(
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", prefix="diffusion_bench_tmp_", delete=False) as tmp:
tmp_result_file = Path(tmp.name)
exclude_keys = {"baseline", "dataset", "task", "name", "skip-performance-assertion"}
exclude_keys = {"baseline", "dataset", "task", "name"}
cmd = [
sys.executable,
@@ -772,12 +810,17 @@ def run_benchmark(
completed = metrics.get("completed_requests", metrics.get("completed", 0))
failed = metrics.get("failed_requests", metrics.get("failed", 0))
# Persist sweep-resolved baseline from params (already narrowed in _build_run_params).
baseline = params.get("baseline") or {}
metrics["baseline"] = baseline
record: dict[str, Any] = {
"test_name": test_name,
"endpoint": endpoint,
"timestamp": timestamp,
"server_params": server_cfg.get("server_params"),
"benchmark_params": params,
"baseline": baseline,
"result": metrics,
"log_file": str(log_file),
"Model": model,
@@ -827,70 +870,14 @@ def run_benchmark(
# ---------------------------------------------------------------------------
def _resolve_baseline_value(
baseline_raw: Any,
*,
sweep_index: int | None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> Any:
"""Pick the baseline threshold for this sweep step."""
if baseline_raw is None:
return 100000
if isinstance(baseline_raw, dict):
if max_concurrency is not None:
for key in (max_concurrency, str(max_concurrency)):
if key in baseline_raw:
return baseline_raw[key]
if request_rate is not None:
for key in (request_rate, str(request_rate)):
if key in baseline_raw:
return baseline_raw[key]
raise KeyError(
f"baseline dict has no key for max_concurrency={max_concurrency!r} "
f"or request_rate={request_rate!r}; keys={list(baseline_raw.keys())!r}"
)
if isinstance(baseline_raw, (list, tuple)):
if sweep_index is None:
raise ValueError("sweep_index is required when baseline is a list or tuple")
return baseline_raw[sweep_index]
return baseline_raw
def assert_result(
result: dict[str, Any],
params: dict[str, Any],
num_prompts: int,
*,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
assert_baseline: bool = True,
) -> None:
"""Assert that benchmark metrics satisfy the configured baselines."""
"""Assert that the expected number of requests completed."""
completed = result.get("completed_requests", result.get("completed", 0))
assert completed == num_prompts, f"Expected {num_prompts} completed requests, got {completed}"
if not assert_baseline:
return
if params.get("skip-performance-assertion", False):
print("Skipping performance assertions.")
return
for metric, baseline_raw in params.get("baseline", {}).items():
current = result.get(metric)
assert current is not None, f"Metric '{metric}' not found in result: {list(result.keys())}"
threshold = _resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
if "throughput" in metric:
assert current >= threshold, f"{metric}: {current:.4f} < baseline {threshold}"
else:
assert current <= threshold, f"{metric}: {current:.4f} > baseline {threshold}"
def _default_benchmark_endpoint_for_task(task: str) -> str:
"""Return the default client-side benchmark endpoint for a diffusion task."""
@@ -932,15 +919,11 @@ def _build_run_params(
if max_concurrency is not None:
run_params["max-concurrency"] = max_concurrency
if "baseline" in params:
run_params["baseline"] = {
metric: _resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
for metric, baseline_raw in params["baseline"].items()
}
# 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
@@ -963,36 +946,30 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
sweep_runs: list[dict[str, Any]] = []
for i, (request_rate, num_prompts) in enumerate(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(
{
"params": _build_run_params(
params,
request_rate=request_rate,
num_prompts=num_prompts,
sweep_index=i,
sweep_index=sweep_index,
),
"num_prompts": num_prompts,
"sweep_index": i,
"request_rate": request_rate,
"max_concurrency": None,
}
)
for i, (max_concurrency, num_prompts) in enumerate(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(
{
"params": _build_run_params(
params,
max_concurrency=max_concurrency,
num_prompts=num_prompts,
sweep_index=i,
request_rate="inf",
sweep_index=sweep_index,
),
"num_prompts": num_prompts,
"sweep_index": i,
"request_rate": None,
"max_concurrency": max_concurrency,
}
)
@@ -1003,11 +980,9 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
"params": _build_run_params(
params,
num_prompts=default_num_prompts,
sweep_index=0,
),
"num_prompts": default_num_prompts,
"sweep_index": None,
"request_rate": None,
"max_concurrency": None,
}
)
@@ -1023,14 +998,12 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
paired_benchmark_params,
indirect=["diffusion_server", "benchmark_params"],
)
def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, request):
def test_diffusion_performance_benchmark(diffusion_server, benchmark_params):
"""Run the diffusion performance benchmark and verify request completion.
One server is started per unique parallel configuration (module scope).
For each server, all benchmark parameter sets defined in the config JSON
are executed sequentially; metrics are recorded to the aggregated result file.
Pass ``--assert-baseline`` to compare ``throughput_qps``, ``latency_mean``, etc. to the JSON ``baseline`` block.
"""
test_name = benchmark_params["test_name"]
params = benchmark_params["params"]
@@ -1073,14 +1046,4 @@ def test_diffusion_performance_benchmark(diffusion_server, benchmark_params, req
print(f"\n Aggregated results: {_aggregated_result_file_for_source(str(source))}")
print("=" * 60)
assert_baseline = request.config.getoption("--assert-baseline", default=False)
assert_result(
result,
params,
sweep_run["num_prompts"],
sweep_index=sweep_run["sweep_index"],
max_concurrency=sweep_run["max_concurrency"],
request_rate=sweep_run["request_rate"],
assert_baseline=assert_baseline,
)
assert_result(result, sweep_run["num_prompts"])
+24 -16
View File
@@ -40,10 +40,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.495,
"latency_mean": 2.0238,
"peak_memory_mb_max": 29393.4286,
"peak_memory_mb_mean": 29392.7143
"H100": {
"throughput_qps": 0.495,
"latency_mean": 2.0238,
"peak_memory_mb_max": 29393.4286,
"peak_memory_mb_mean": 29392.7143
}
}
}
]
@@ -90,10 +92,12 @@
"enable-negative-prompt": true,
"num-input-images": 1,
"baseline": {
"throughput_qps": 0.3451,
"latency_mean": 2.9031,
"peak_memory_mb_max": 30480.2857,
"peak_memory_mb_mean": 30479.2857
"H100": {
"throughput_qps": 0.3451,
"latency_mean": 2.9031,
"peak_memory_mb_max": 30480.2857,
"peak_memory_mb_mean": 30479.2857
}
}
}
]
@@ -139,10 +143,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.5048,
"latency_mean": 1.987,
"peak_memory_mb_max": 29380.0,
"peak_memory_mb_mean": 29380.0
"H100": {
"throughput_qps": 0.5048,
"latency_mean": 1.987,
"peak_memory_mb_max": 29380.0,
"peak_memory_mb_mean": 29380.0
}
}
}
]
@@ -189,10 +195,12 @@
"enable-negative-prompt": true,
"num-input-images": 1,
"baseline": {
"throughput_qps": 0.149,
"latency_mean": 6.7157,
"peak_memory_mb_max": 30574.0,
"peak_memory_mb_mean": 30574.0
"H100": {
"throughput_qps": 0.149,
"latency_mean": 6.7157,
"peak_memory_mb_max": 30574.0,
"peak_memory_mb_mean": 30574.0
}
}
}
]
@@ -24,10 +24,12 @@
"warmup-num-inference-steps": 28,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": {"1": 0.0303, "2": 0.0302, "4": 0.0302},
"latency_mean": {"1": 32.6, "2": 62.2, "4": 111.7},
"peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
"H100": {
"throughput_qps": [0.0303, 0.0302, 0.0302],
"latency_mean": [32.6, 62.2, 111.7],
"peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
}
}
}
]
@@ -24,10 +24,12 @@
"warmup-num-inference-steps": 28,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": {"1": 0.0549, "2": 0.0547, "4": 0.0549},
"latency_mean": {"1": 18.0, "2": 34.3, "4": 61.4},
"peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
"H100": {
"throughput_qps": [0.0549, 0.0547, 0.0549],
"latency_mean": [18.0, 34.3, 61.4],
"peak_memory_mb_max": 40500,
"peak_memory_mb_mean": 40500
}
}
}
]
@@ -48,9 +48,11 @@
"guardrails": false
},
"baseline": {
"throughput_qps": 1.107,
"latency_mean": 0.8929,
"peak_memory_mb_mean": 80000.0
"H100": {
"throughput_qps": 1.107,
"latency_mean": 0.8929,
"peak_memory_mb_mean": 80000.0
}
}
}
]
@@ -115,9 +117,11 @@
"use_duration_template": false
},
"baseline": {
"throughput_qps": 0.027,
"latency_mean": 36.6824,
"peak_memory_mb_mean": 30155.4
"H100": {
"throughput_qps": 0.027,
"latency_mean": 36.6824,
"peak_memory_mb_mean": 30155.4
}
}
}
]
@@ -181,9 +185,11 @@
"guardrails": false
},
"baseline": {
"throughput_qps": 0.027,
"latency_mean": 36.968,
"peak_memory_mb_mean": 30443.6
"H100": {
"throughput_qps": 0.027,
"latency_mean": 36.968,
"peak_memory_mb_mean": 30443.6
}
}
}
]
@@ -251,9 +257,11 @@
"condition_video_keep": "first"
},
"baseline": {
"throughput_qps": 0.027,
"latency_mean": 37.2117,
"peak_memory_mb_mean": 30135.6
"H100": {
"throughput_qps": 0.027,
"latency_mean": 37.2117,
"peak_memory_mb_mean": 30135.6
}
}
}
]
+19 -15
View File
@@ -46,12 +46,14 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
223.3584
],
"median_audio_rtf": [
0.4393
]
"H100": {
"median_audio_ttfp_ms": [
223.3584
],
"median_audio_rtf": [
0.4393
]
}
}
},
{
@@ -70,15 +72,17 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
276.575
],
"median_audio_rtf": [
0.5073
],
"audio_throughput": [
27.3728
]
"H100": {
"median_audio_ttfp_ms": [
276.575
],
"median_audio_rtf": [
0.5073
],
"audio_throughput": [
27.3728
]
}
}
}
]
@@ -133,13 +133,14 @@
"bot-task": "think_recaption",
"max-concurrency": 16,
"num-prompts": 96,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.1874,
"latency_p99": 101.852,
"latency_mean": 79.0385,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
"H200": {
"throughput_qps": 0.1874,
"latency_p99": 101.852,
"latency_mean": 79.0385,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
}
}
}
]
@@ -37,12 +37,13 @@
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.21,
"latency_p99": 4.7469,
"peak_memory_mb_max": 101100,
"peak_memory_mb_mean": 101100
"H200": {
"throughput_qps": 0.21,
"latency_p99": 4.7469,
"peak_memory_mb_max": 101100,
"peak_memory_mb_mean": 101100
}
}
}
]
@@ -37,12 +37,13 @@
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.20,
"latency_p99": 5.1025,
"peak_memory_mb_max": 97402,
"peak_memory_mb_mean": 97402
"H200": {
"throughput_qps": 0.20,
"latency_p99": 5.1025,
"peak_memory_mb_max": 97402,
"peak_memory_mb_mean": 97402
}
}
}
]
@@ -36,13 +36,14 @@
"num-inference-steps": 8,
"num-prompts": 10,
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.213,
"latency_mean": 4.6953,
"latency_p99": 4.8601,
"peak_memory_mb_max": 56912.0,
"peak_memory_mb_mean": 56912.0
"H200": {
"throughput_qps": 0.213,
"latency_mean": 4.6953,
"latency_p99": 4.8601,
"peak_memory_mb_max": 56912.0,
"peak_memory_mb_mean": 56912.0
}
}
}
]
@@ -26,8 +26,7 @@
"warmup-requests": 1,
"warmup-concurrency": 1,
"warmup-num-inference-steps": 2,
"enable-negative-prompt": true,
"skip-performance-assertion": true
"enable-negative-prompt": true
}
]
}
@@ -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
}
}
}
]
}
]
@@ -44,27 +44,29 @@
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
96.4061,
140.8147,
271.9025,
362.2916,
507.776
],
"mean_e2el_ms": [
18507.4582,
28365.4956,
31907.9613,
48161.5829,
72630.8601
],
"mean_audio_rtf": [
0.1175,
0.18,
0.2359,
0.3278,
0.6296
]
"H100": {
"mean_ttft_ms": [
96.4061,
140.8147,
271.9025,
362.2916,
507.776
],
"mean_e2el_ms": [
18507.4582,
28365.4956,
31907.9613,
48161.5829,
72630.8601
],
"mean_audio_rtf": [
0.1175,
0.18,
0.2359,
0.3278,
0.6296
]
}
}
},
{
@@ -91,15 +93,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
354.0951
],
"mean_e2el_ms": [
3952.5615
],
"mean_audio_rtf": [
0.117
]
"H100": {
"mean_ttft_ms": [
354.0951
],
"mean_e2el_ms": [
3952.5615
],
"mean_audio_rtf": [
0.117
]
}
}
},
{
@@ -128,15 +132,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
259.6317
],
"mean_e2el_ms": [
4253.3009
],
"mean_audio_rtf": [
0.1448
]
"H100": {
"mean_ttft_ms": [
259.6317
],
"mean_e2el_ms": [
4253.3009
],
"mean_audio_rtf": [
0.1448
]
}
}
},
{
@@ -167,15 +173,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
457.4453
],
"mean_e2el_ms": [
5912.5623
],
"mean_audio_rtf": [
0.1967
]
"H100": {
"mean_ttft_ms": [
457.4453
],
"mean_e2el_ms": [
5912.5623
],
"mean_audio_rtf": [
0.1967
]
}
}
},
{
@@ -206,20 +214,22 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
90.8033,
202.1779,
326.5864,
544.8914,
961.3929
],
"mean_e2el_ms": [
4517.3411,
7857.9711,
9861.1687,
13283.3363,
19541.0736
]
"H100": {
"mean_ttft_ms": [
90.8033,
202.1779,
326.5864,
544.8914,
961.3929
],
"mean_e2el_ms": [
4517.3411,
7857.9711,
9861.1687,
13283.3363,
19541.0736
]
}
}
},
{
@@ -251,12 +261,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
318.048
],
"mean_e2el_ms": [
807.6114
]
"H100": {
"mean_ttft_ms": [
318.048
],
"mean_e2el_ms": [
807.6114
]
}
}
},
{
@@ -290,12 +302,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
186.168
],
"mean_e2el_ms": [
726.1137
]
"H100": {
"mean_ttft_ms": [
186.168
],
"mean_e2el_ms": [
726.1137
]
}
}
},
{
@@ -331,12 +345,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
375.5282
],
"mean_e2el_ms": [
1006.1663
]
"H100": {
"mean_ttft_ms": [
375.5282
],
"mean_e2el_ms": [
1006.1663
]
}
}
}
]
@@ -35,10 +35,12 @@
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [130.5751, 278.6469, 324.7249, 911.6085],
"mean_tpot_ms": [7.9854, 11.1209, 16.1507, 20.7485],
"mean_audio_ttfp_ms": [486.486, 954.1791, 1289.6514, 2196.8275],
"mean_audio_rtf": [0.1896, 0.3962, 0.4098, 0.7322]
"H100": {
"mean_ttft_ms": [130.5751, 278.6469, 324.7249, 911.6085],
"mean_tpot_ms": [7.9854, 11.1209, 16.1507, 20.7485],
"mean_audio_ttfp_ms": [486.486, 954.1791, 1289.6514, 2196.8275],
"mean_audio_rtf": [0.1896, 0.3962, 0.4098, 0.7322]
}
}
},
{
@@ -61,9 +63,11 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [340.5122],
"mean_audio_ttfp_ms": [463.0752],
"mean_audio_rtf": [0.1153]
"H100": {
"mean_ttft_ms": [340.5122],
"mean_audio_ttfp_ms": [463.0752],
"mean_audio_rtf": [0.1153]
}
}
},
{
@@ -88,9 +92,11 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [236.926],
"mean_audio_ttfp_ms": [395.2182],
"mean_audio_rtf": [0.1259]
"H100": {
"mean_ttft_ms": [236.926],
"mean_audio_ttfp_ms": [395.2182],
"mean_audio_rtf": [0.1259]
}
}
},
{
@@ -117,9 +123,11 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [470.6126],
"mean_audio_ttfp_ms": [665.5474],
"mean_audio_rtf": [0.166]
"H100": {
"mean_ttft_ms": [470.6126],
"mean_audio_ttfp_ms": [665.5474],
"mean_audio_rtf": [0.166]
}
}
}
]
@@ -44,27 +44,29 @@
"ignore_eos": true,
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
115.8348,
173.1823,
446.672,
464.5985,
3420.0634
],
"mean_e2el_ms": [
25070.9523,
31992.0646,
37619.7669,
50393.8257,
136619.2743
],
"mean_audio_rtf": [
0.169,
0.2111,
0.2475,
0.3551,
0.9266
]
"H100": {
"mean_ttft_ms": [
115.8348,
173.1823,
446.672,
464.5985,
3420.0634
],
"mean_e2el_ms": [
25070.9523,
31992.0646,
37619.7669,
50393.8257,
136619.2743
],
"mean_audio_rtf": [
0.169,
0.2111,
0.2475,
0.3551,
0.9266
]
}
}
},
{
@@ -91,15 +93,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
343.1377
],
"mean_e2el_ms": [
4811.1123
],
"mean_audio_rtf": [
0.1463
]
"H100": {
"mean_ttft_ms": [
343.1377
],
"mean_e2el_ms": [
4811.1123
],
"mean_audio_rtf": [
0.1463
]
}
}
},
{
@@ -128,15 +132,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
245.5446
],
"mean_e2el_ms": [
5019.1256
],
"mean_audio_rtf": [
0.1713
]
"H100": {
"mean_ttft_ms": [
245.5446
],
"mean_e2el_ms": [
5019.1256
],
"mean_audio_rtf": [
0.1713
]
}
}
},
{
@@ -167,15 +173,17 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
506.1407
],
"mean_e2el_ms": [
6814.9656
],
"mean_audio_rtf": [
0.2213
]
"H100": {
"mean_ttft_ms": [
506.1407
],
"mean_e2el_ms": [
6814.9656
],
"mean_audio_rtf": [
0.2213
]
}
}
}
]
@@ -51,20 +51,22 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
69.8081,
173.0651,
271.8108,
413.4079,
497.2546
],
"mean_e2el_ms": [
4720.8225,
7937.2532,
9962.3566,
13724.5479,
18324.091
]
"H100": {
"mean_ttft_ms": [
69.8081,
173.0651,
271.8108,
413.4079,
497.2546
],
"mean_e2el_ms": [
4720.8225,
7937.2532,
9962.3566,
13724.5479,
18324.091
]
}
}
},
{
@@ -97,12 +99,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
25.9477
],
"mean_e2el_ms": [
517.3155
]
"H100": {
"mean_ttft_ms": [
25.9477
],
"mean_e2el_ms": [
517.3155
]
}
}
},
{
@@ -137,12 +141,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
26.7056
],
"mean_e2el_ms": [
551.308
]
"H100": {
"mean_ttft_ms": [
26.7056
],
"mean_e2el_ms": [
551.308
]
}
}
},
{
@@ -179,12 +185,14 @@
},
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
27.6906
],
"mean_e2el_ms": [
596.5284
]
"H100": {
"mean_ttft_ms": [
27.6906
],
"mean_e2el_ms": [
596.5284
]
}
}
}
]
@@ -34,10 +34,12 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0721,
"latency_mean": 13.8398,
"peak_memory_mb_max": 57610.0,
"peak_memory_mb_mean": 57610.0
"H100": {
"throughput_qps": 0.0721,
"latency_mean": 13.8398,
"peak_memory_mb_max": 57610.0,
"peak_memory_mb_mean": 57610.0
}
}
},
{
@@ -52,10 +54,12 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0186,
"latency_mean": 53.6942,
"peak_memory_mb_max": 67790.0,
"peak_memory_mb_mean": 67790.0
"H100": {
"throughput_qps": 0.0186,
"latency_mean": 53.6942,
"peak_memory_mb_max": 67790.0,
"peak_memory_mb_mean": 67790.0
}
}
}
]
@@ -100,10 +104,12 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0538,
"latency_mean": 18.5591,
"peak_memory_mb_max": 56449.4286,
"peak_memory_mb_mean": 56449.4286
"H100": {
"throughput_qps": 0.0538,
"latency_mean": 18.5591,
"peak_memory_mb_max": 56449.4286,
"peak_memory_mb_mean": 56449.4286
}
}
}
]
@@ -157,10 +163,12 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.2776,
"latency_mean": 3.5938,
"peak_memory_mb_max": 58113.7143,
"peak_memory_mb_mean": 58075.1429
"H100": {
"throughput_qps": 0.2776,
"latency_mean": 3.5938,
"peak_memory_mb_max": 58113.7143,
"peak_memory_mb_mean": 58075.1429
}
}
},
{
@@ -175,10 +183,12 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1051,
"latency_mean": 9.4918,
"peak_memory_mb_max": 68107.4286,
"peak_memory_mb_mean": 68107.4286
"H100": {
"throughput_qps": 0.1051,
"latency_mean": 9.4918,
"peak_memory_mb_max": 68107.4286,
"peak_memory_mb_mean": 68107.4286
}
}
}
]
@@ -33,10 +33,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1129,
"latency_mean": 8.8352,
"peak_memory_mb_max": 58572.0,
"peak_memory_mb_mean": 58572.0
"H100": {
"throughput_qps": 0.1129,
"latency_mean": 8.8352,
"peak_memory_mb_max": 58572.0,
"peak_memory_mb_mean": 58572.0
}
}
},
{
@@ -50,10 +52,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.024,
"latency_mean": 41.5686,
"peak_memory_mb_max": 66368.0,
"peak_memory_mb_mean": 66368.0
"H100": {
"throughput_qps": 0.024,
"latency_mean": 41.5686,
"peak_memory_mb_max": 66368.0,
"peak_memory_mb_mean": 66368.0
}
}
}
]
@@ -96,10 +100,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0672,
"latency_mean": 14.8517,
"peak_memory_mb_max": 57076.0,
"peak_memory_mb_mean": 57076.0
"H100": {
"throughput_qps": 0.0672,
"latency_mean": 14.8517,
"peak_memory_mb_max": 57076.0,
"peak_memory_mb_mean": 57076.0
}
}
}
]
@@ -152,10 +158,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.2879,
"latency_mean": 3.4701,
"peak_memory_mb_max": 58758.0,
"peak_memory_mb_mean": 58758.0
"H100": {
"throughput_qps": 0.2879,
"latency_mean": 3.4701,
"peak_memory_mb_max": 58758.0,
"peak_memory_mb_mean": 58758.0
}
}
},
{
@@ -169,10 +177,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1113,
"latency_mean": 8.9656,
"peak_memory_mb_max": 67424.0,
"peak_memory_mb_mean": 67424.0
"H100": {
"throughput_qps": 0.1113,
"latency_mean": 8.9656,
"peak_memory_mb_max": 67424.0,
"peak_memory_mb_mean": 67424.0
}
}
}
]
@@ -33,10 +33,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0703,
"latency_mean": 14.208,
"peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
"H100": {
"throughput_qps": 0.0703,
"latency_mean": 14.208,
"peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
}
}
},
{
@@ -50,10 +52,12 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0417,
"latency_mean": 23.914,
"peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
"H100": {
"throughput_qps": 0.0417,
"latency_mean": 23.914,
"peak_memory_mb_max": 63225.1429,
"peak_memory_mb_mean": 63225.1429
}
}
}
]
@@ -33,9 +33,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.4153,
"latency_mean": 2.4065,
"peak_memory_mb_mean": 55121.4286
"H100": {
"throughput_qps": 0.4153,
"latency_mean": 2.4065,
"peak_memory_mb_mean": 55121.4286
}
}
},
{
@@ -49,9 +51,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0423,
"latency_mean": 23.5991,
"peak_memory_mb_mean": 65118.0
"H100": {
"throughput_qps": 0.0423,
"latency_mean": 23.5991,
"peak_memory_mb_mean": 65118.0
}
}
}
]
@@ -91,9 +95,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.4175,
"latency_mean": 2.3953,
"peak_memory_mb_mean": 55088.5714
"H100": {
"throughput_qps": 0.4175,
"latency_mean": 2.3953,
"peak_memory_mb_mean": 55088.5714
}
}
},
{
@@ -107,9 +113,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.0423,
"latency_mean": 23.5768,
"peak_memory_mb_mean": 66012.2857
"H100": {
"throughput_qps": 0.0423,
"latency_mean": 23.5768,
"peak_memory_mb_mean": 66012.2857
}
}
}
]
@@ -152,9 +160,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1214,
"latency_mean": 8.2211,
"peak_memory_mb_mean": 54501.1429
"H100": {
"throughput_qps": 0.1214,
"latency_mean": 8.2211,
"peak_memory_mb_mean": 54501.1429
}
}
}
]
@@ -207,9 +217,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.559,
"latency_mean": 1.794,
"peak_memory_mb_mean": 55290.5714
"H100": {
"throughput_qps": 0.559,
"latency_mean": 1.794,
"peak_memory_mb_mean": 55290.5714
}
}
},
{
@@ -223,9 +235,11 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1918,
"latency_mean": 5.2022,
"peak_memory_mb_mean": 64838.8571
"H100": {
"throughput_qps": 0.1918,
"latency_mean": 5.2022,
"peak_memory_mb_mean": 64838.8571
}
}
}
]
@@ -269,9 +283,11 @@
"warmup-num-inference-steps": 20,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159],
"latency_mean": [2.4215, 2.992, 5.9293, 9.656],
"peak_memory_mb_mean": [55147.4286, 55148.0, 55150.4643, 55152.3821]
"H100": {
"throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159],
"latency_mean": [2.4215, 2.992, 5.9293, 9.656],
"peak_memory_mb_mean": [55147.4286, 55148.0, 55150.4643, 55152.3821]
}
}
}
]
+184 -35
View File
@@ -17,7 +17,7 @@ def test_task_excluded_from_cli_args():
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"percentile-metrics": "audio_rtf,audio_ttfp",
"baseline": {"mean_audio_rtf": [0.5]},
"baseline": {"H100": {"mean_audio_rtf": [0.5]}},
}
exclude_keys = {
"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():
from tests.dfx.conftest import resource_label_for_filename
@@ -169,16 +140,14 @@ def test_hardware_json_value():
assert hardware_json_value(None) == ""
def test_extract_configs_resource_label(monkeypatch):
from tests.dfx.conftest import extract_configs_resource_label, get_runtime_resource_label
def test_get_runtime_resource_label(monkeypatch):
from tests.dfx.conftest import get_runtime_resource_label
monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name",
lambda *, device_id=0: "NVIDIA H100 80GB HBM3",
)
get_runtime_resource_label(refresh=True)
assert extract_configs_resource_label([]) == "H100"
get_runtime_resource_label(refresh=True)
assert get_runtime_resource_label(refresh=True) == "H100"
monkeypatch.setattr(
"tests.dfx.conftest._read_runtime_device_name",
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),
("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)
+137 -111
View File
@@ -39,12 +39,14 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
141.2791
],
"median_audio_rtf": [
0.1591
]
"H100": {
"median_audio_ttfp_ms": [
141.2791
],
"median_audio_rtf": [
0.1591
]
}
}
},
{
@@ -67,21 +69,23 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
226.747,
647.1272,
4777.2714
],
"median_audio_rtf": [
0.3313,
0.3642,
1.2282
],
"audio_throughput": [
24.2296,
40.3552,
43.2572
]
"H100": {
"median_audio_ttfp_ms": [
226.747,
647.1272,
4777.2714
],
"median_audio_rtf": [
0.3313,
0.3642,
1.2282
],
"audio_throughput": [
24.2296,
40.3552,
43.2572
]
}
}
},
{
@@ -102,9 +106,11 @@
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.45
]
"H100": {
"mean_audio_rtf": [
0.45
]
}
}
}
]
@@ -153,12 +159,14 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
46.8434
],
"median_audio_rtf": [
0.1367
]
"H100": {
"median_audio_ttfp_ms": [
46.8434
],
"median_audio_rtf": [
0.1367
]
}
}
},
{
@@ -186,21 +194,23 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
75.3341,
713.7727,
5941.7179
],
"median_audio_rtf": [
0.1859,
0.3148,
1.1662
],
"audio_throughput": [
38.3858,
55.3523,
68.7032
]
"H100": {
"median_audio_ttfp_ms": [
75.3341,
713.7727,
5941.7179
],
"median_audio_rtf": [
0.1859,
0.3148,
1.1662
],
"audio_throughput": [
38.3858,
55.3523,
68.7032
]
}
}
},
{
@@ -226,9 +236,11 @@
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.35
]
"H100": {
"mean_audio_rtf": [
0.35
]
}
}
},
{
@@ -252,15 +264,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
56.746
],
"median_audio_rtf": [
0.1571
],
"audio_throughput": [
11.1226
]
"H100": {
"median_audio_ttfp_ms": [
56.746
],
"median_audio_rtf": [
0.1571
],
"audio_throughput": [
11.1226
]
}
}
},
{
@@ -283,12 +297,14 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
46.3479
],
"median_audio_rtf": [
0.1352
]
"H100": {
"median_audio_ttfp_ms": [
46.3479
],
"median_audio_rtf": [
0.1352
]
}
}
},
{
@@ -315,21 +331,23 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
75.5967,
587.5877,
4960.3726
],
"median_audio_rtf": [
0.1871,
0.3143,
1.1703
],
"audio_throughput": [
38.9378,
55.1846,
71.5708
]
"H100": {
"median_audio_ttfp_ms": [
75.5967,
587.5877,
4960.3726
],
"median_audio_rtf": [
0.1871,
0.3143,
1.1703
],
"audio_throughput": [
38.9378,
55.1846,
71.5708
]
}
}
},
{
@@ -352,15 +370,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
55.5029
],
"median_audio_rtf": [
0.1508
],
"audio_throughput": [
9.4885
]
"H100": {
"median_audio_ttfp_ms": [
55.5029
],
"median_audio_rtf": [
0.1508
],
"audio_throughput": [
9.4885
]
}
}
}
]
@@ -402,12 +422,14 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
82.1886
],
"median_audio_rtf": [
0.1241
]
"H100": {
"median_audio_ttfp_ms": [
82.1886
],
"median_audio_rtf": [
0.1241
]
}
}
},
{
@@ -429,15 +451,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
160.9168
],
"median_audio_rtf": [
0.1907
],
"audio_throughput": [
39.2332
]
"H100": {
"median_audio_ttfp_ms": [
160.9168
],
"median_audio_rtf": [
0.1907
],
"audio_throughput": [
39.2332
]
}
}
},
{
@@ -459,15 +483,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
100.1265
],
"median_audio_rtf": [
0.1437
],
"audio_throughput": [
11.7605
]
"H100": {
"median_audio_ttfp_ms": [
100.1265
],
"median_audio_rtf": [
0.1437
],
"audio_throughput": [
11.7605
]
}
}
}
]
+59 -45
View File
@@ -37,12 +37,14 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
137.645
],
"median_audio_rtf": [
0.0497
]
"H100": {
"median_audio_ttfp_ms": [
137.645
],
"median_audio_rtf": [
0.0497
]
}
}
},
{
@@ -61,15 +63,17 @@
"seed_tts_locale": "en",
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
475.3357
],
"median_audio_rtf": [
0.1243
],
"audio_throughput": [
36.6358
]
"H100": {
"median_audio_ttfp_ms": [
475.3357
],
"median_audio_rtf": [
0.1243
],
"audio_throughput": [
36.6358
]
}
}
},
{
@@ -90,9 +94,11 @@
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.45
]
"H100": {
"mean_audio_rtf": [
0.45
]
}
}
},
{
@@ -114,12 +120,14 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
79.6927
],
"median_audio_rtf": [
0.0423
]
"H100": {
"median_audio_ttfp_ms": [
79.6927
],
"median_audio_rtf": [
0.0423
]
}
}
},
{
@@ -141,15 +149,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
262.6212
],
"median_audio_rtf": [
0.0753
],
"audio_throughput": [
103.5843
]
"H100": {
"median_audio_ttfp_ms": [
262.6212
],
"median_audio_rtf": [
0.0753
],
"audio_throughput": [
103.5843
]
}
}
},
{
@@ -171,15 +181,17 @@
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
80.1095
],
"median_audio_rtf": [
0.0479
],
"audio_throughput": [
17.2918
]
"H100": {
"median_audio_ttfp_ms": [
80.1095
],
"median_audio_rtf": [
0.0479
],
"audio_throughput": [
17.2918
]
}
}
},
{
@@ -203,9 +215,11 @@
"seed_tts_wer_eval": true,
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_rtf": [
0.35
]
"H100": {
"mean_audio_rtf": [
0.35
]
}
}
}
]
@@ -42,9 +42,11 @@
}
],
"baseline": {
"throughput_qps": 0.0434,
"latency_mean": 23.0654,
"peak_memory_mb_mean": 76360.0
"H100": {
"throughput_qps": 0.0434,
"latency_mean": 23.0654,
"peak_memory_mb_mean": 76360.0
}
}
}
]
@@ -97,9 +99,11 @@
}
],
"baseline": {
"throughput_qps": 0.0606,
"latency_mean": 16.4928,
"peak_memory_mb_mean": 47164.8
"H100": {
"throughput_qps": 0.0606,
"latency_mean": 16.4928,
"peak_memory_mb_mean": 47164.8
}
}
},
{
@@ -122,9 +126,11 @@
}
],
"baseline": {
"throughput_qps": 0.0124,
"latency_mean": 80.7784,
"peak_memory_mb_mean": 55374.3429
"H100": {
"throughput_qps": 0.0124,
"latency_mean": 80.7784,
"peak_memory_mb_mean": 55374.3429
}
}
}
]
@@ -90,6 +90,35 @@ class TestAttentionSpec:
with pytest.raises(ValueError, match=match):
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:
def test_empty_config(self):
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import nullcontext
import pytest
import torch
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
from vllm_omni.diffusion.attention.backends.cudnn_attn import CuDNNAttentionImpl
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
def test_cudnn_slices_valid_kv_prefix_without_padding_mask(monkeypatch):
observed = {}
def fake_sdpa(query, key, value, **kwargs):
observed.update(query=query, key=key, value=value, kwargs=kwargs)
return query
monkeypatch.setattr(
"vllm_omni.diffusion.attention.backends.cudnn_attn.sdpa_kernel",
lambda _backends: nullcontext(),
)
monkeypatch.setattr(torch.nn.functional, "scaled_dot_product_attention", fake_sdpa)
impl = CuDNNAttentionImpl(
num_heads=2,
head_size=4,
softmax_scale=0.5,
)
query = torch.randn(1, 8, 2, 4)
key = torch.randn_like(query)
value = torch.randn_like(query)
output = impl.forward_cuda(
query,
key,
value,
AttentionMetadata(extra={"valid_kv_length": 5}),
)
assert output.shape == query.shape
assert observed["query"].shape == (1, 2, 8, 4)
assert observed["key"].shape == (1, 2, 5, 4)
assert observed["value"].shape == (1, 2, 5, 4)
assert observed["kwargs"]["attn_mask"] is None
def test_cudnn_rejects_invalid_valid_kv_length():
impl = CuDNNAttentionImpl(
num_heads=2,
head_size=4,
softmax_scale=0.5,
)
query = torch.randn(1, 8, 2, 4)
with pytest.raises(ValueError, match="valid_kv_length"):
impl.forward_cuda(
query,
query,
query,
AttentionMetadata(extra={"valid_kv_length": 9}),
)
@@ -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
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
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)
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(
"bk, field",
@@ -187,6 +191,7 @@ def test_mask_free_allowlist_gates_trtllm_default():
assert meta("WanPipeline").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(None).attention_mask_free is False
@@ -204,6 +209,91 @@ def test_masked_layer_raises():
_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):
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"
@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(
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)",
@@ -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"
@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
def test_skip_tiny_threshold_matches_dense():
torch.manual_seed(0)
+31 -5
View File
@@ -86,9 +86,35 @@ class TestCacheDiTBackend:
# Verify cache-dit was enabled
assert backend.enabled is True
assert backend._refresh_func is not None
assert backend._refresh_funcs
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.cache_dit")
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)
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
mock_logger.info.assert_any_call(
"Transformer %s does not declare _cache_dit_adapter_config; "
@@ -138,7 +164,7 @@ class TestCacheDiTBackend:
assert isinstance(exc_info.value.__cause__, ValueError)
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.cache_dit")
@@ -245,7 +271,7 @@ class TestCacheDiTBackend:
backend.enable(mock_pipeline)
assert backend.enabled is True
assert backend._refresh_func is not None
assert backend._refresh_funcs
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.model
@@ -285,7 +311,7 @@ class TestCacheDiTBackend:
backend.enable(mock_pipeline)
assert backend.enabled is True
assert backend._refresh_func is not None
assert backend._refresh_funcs
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.transformer
+41
View File
@@ -2,6 +2,9 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for LayerNorm and RMSNorm custom ops in diffusion layers."""
import sys
import types
import pytest
import torch
from pytest_mock import MockerFixture
@@ -299,6 +302,44 @@ def test_rmsnorm_numerical_correctness():
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
def test_rmsnorm_dtype_matches_parameter_and_fp32_accumulation_contract():
"""dtype controls gamma storage; reduced-precision inputs still use fp32 accumulation."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
eps = 1e-6
norm = RMSNorm(hidden_size, eps=eps, dtype=torch.bfloat16)
x = torch.randn(2, 4, hidden_size, dtype=torch.bfloat16)
assert norm.weight.dtype == torch.bfloat16
x_fp32 = x.float()
expected = (x_fp32 * torch.rsqrt(x_fp32.square().mean(-1, keepdim=True) + eps) * norm.weight.float()).to(x.dtype)
torch.testing.assert_close(norm.forward_native(x), expected, atol=0, rtol=0)
def test_rmsnorm_npu_receives_gamma_with_configured_dtype(monkeypatch: pytest.MonkeyPatch):
"""The NPU fused op receives the bf16 checkpoint gamma used by H3."""
from vllm_omni.diffusion.layers.norm import RMSNorm
captured: dict[str, torch.Tensor] = {}
def npu_rms_norm(x: torch.Tensor, gamma: torch.Tensor, epsilon: float):
captured["gamma"] = gamma
captured["epsilon"] = torch.tensor(epsilon)
return (x,)
monkeypatch.setitem(sys.modules, "torch_npu", types.SimpleNamespace(npu_rms_norm=npu_rms_norm))
norm = RMSNorm(64, eps=1e-5, dtype=torch.bfloat16)
x = torch.randn(2, 4, 64, dtype=torch.bfloat16)
assert norm.forward_npu(x) is x
assert captured["gamma"] is norm.weight
assert captured["gamma"].dtype == torch.bfloat16
assert captured["epsilon"].item() == pytest.approx(1e-5)
# ── RMSNorm compile-path regression tests ──
+34 -1
View File
@@ -10,10 +10,13 @@ forward_hip, and apply_rotary_emb_mindiesd paths.
from __future__ import annotations
import sys
import types
import pytest
import torch
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
from vllm_omni.diffusion.layers.rope import RotaryEmbedding, apply_rotary_emb_mindiesd
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@@ -234,3 +237,33 @@ class TestRotaryEmbeddingNativeShapeRegression:
output = rope.forward_native(x, cos, sin)
assert output.shape == x.shape
assert not torch.isnan(output).any()
def test_mindie_full_dim_rope_keeps_h3_cos_sin_layout(monkeypatch: pytest.MonkeyPatch) -> None:
"""H3 passes complete rotary dims to MindIE instead of the CUDA half-dim layout."""
captured: dict[str, object] = {}
def rotary_position_embedding(x, cos, sin, **kwargs):
captured.update(x=x, cos=cos, sin=sin, kwargs=kwargs)
return x
monkeypatch.setitem(
sys.modules,
"mindiesd",
types.SimpleNamespace(rotary_position_embedding=rotary_position_embedding),
)
x = torch.randn(11, 3, 96, dtype=torch.bfloat16)
cos = torch.randn(11, 96, dtype=torch.bfloat16)
sin = torch.randn(11, 96, dtype=torch.bfloat16)
actual = apply_rotary_emb_mindiesd(x, cos, sin, interleaved=False, half_head_dim=False)
assert actual is x
assert captured["x"] is x
assert captured["cos"] is cos
assert captured["sin"] is sin
assert captured["kwargs"] == {
"rotated_mode": "rotated_half",
"head_first": False,
"fused": True,
}
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from multiprocessing.reduction import ForkingPickler
from types import SimpleNamespace
from unittest.mock import Mock
@@ -24,12 +25,343 @@ def test_pipeline_import_registry_and_component_discovery():
"pipeline_minimax_h3",
"MiniMaxH3Pipeline",
)
assert _DIFFUSION_MODELS["MiniMaxH3ModularPipeline"] == _DIFFUSION_MODELS["MiniMaxH3Pipeline"]
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._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():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
assert MiniMaxH3Pipeline._offload_plan.offload_submodules == {"token_refiner": "blocks"}
assert MiniMaxH3Pipeline._offload_plan.resident_dit_paths == frozenset({"transformer"})
def test_joint_postprocess_is_multiprocessing_picklable():
from vllm_omni.diffusion.models.minimax_h3 import (
get_minimax_h3_post_process_func,
@@ -87,6 +419,87 @@ def test_shifted_sigma_schedule_matches_reference_values():
)
def test_cudnn_packed_attention_uses_python_length_without_padding_mask():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = True
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
# Model-side Ulysses shards rows before Attention gathers them again.
# The Python packed_total must therefore remain global even though q is
# local here.
q = torch.randn(4, 2, 4)
cu_seqlens = torch.tensor([0, 5, 8], dtype=torch.int32)
output = attention._run_packed_attention(
q,
q,
q,
cu_seqlens=cu_seqlens,
max_seqlen=5,
packed_total=8,
)
assert output.shape == q.shape
assert attention.attention.metadata.attn_mask is None
assert attention.attention.metadata.extra["valid_kv_length"] == 5
def test_packed_attention_keeps_padding_mask_for_other_backends():
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
class FakeBackend:
supports_prefix_kv_slicing = False
class FakeAttention(torch.nn.Module):
attn_backend = FakeBackend
def __init__(self):
super().__init__()
self.metadata = None
def forward(self, query, key, value, metadata):
self.metadata = metadata
return query
attention = object.__new__(MiniMaxH3Attention)
torch.nn.Module.__init__(attention)
attention.attention = FakeAttention()
q = torch.randn(8, 2, 4)
attention._run_packed_attention(
q,
q,
q,
cu_seqlens=torch.tensor([0, 5, 8], dtype=torch.int32),
max_seqlen=5,
packed_total=8,
)
assert torch.equal(
attention.attention.metadata.attn_mask,
torch.tensor([[True, True, True, True, True, False, False, False]]),
)
def test_reference_image_resize_contract():
from PIL import Image
@@ -293,6 +706,142 @@ def test_layerwise_offload_releases_text_encoder():
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_offload_releases_text_encoder():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_cpu_offload=False,
enable_layerwise_offload=False,
enable_distributed_layerwise_offload=True,
)
pipeline.text_encoder = Mock()
expected = torch.ones(2, 3)
pipeline.text_encoder.encode_ids.return_value = expected
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
assert actual is expected
pipeline.text_encoder.load_to_device.assert_called_once_with()
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_offload_stages_vae_component():
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
torch.nn.Module.__init__(pipeline)
pipeline.od_config = SimpleNamespace(
enable_layerwise_offload=False,
enable_distributed_layerwise_offload=True,
)
component = Mock()
with pipeline._component_on_device(component):
component.load_to_device.assert_called_once_with()
component.offload_to_cpu.assert_not_called()
component.offload_to_cpu.assert_called_once_with()
def test_distributed_layerwise_resident_blocks_are_stage_scoped():
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():
controller.load_resident_layers.assert_called_once_with()
controller.offload_resident_layers.assert_not_called()
controller.offload_resident_layers.assert_called_once_with()
def test_distributed_layerwise_resident_blocks_release_on_failure():
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 pytest.raises(RuntimeError, match="denoise failed"):
with pipeline._resident_dit_layers_on_device():
raise RuntimeError("denoise failed")
controller.load_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):
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
)
class Stack(torch.nn.Module):
def __init__(self, count):
super().__init__()
self.blocks = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)])
class TextStack(torch.nn.Module):
def __init__(self, count):
super().__init__()
self.layers = torch.nn.ModuleList([torch.nn.Linear(2, 2) for _ in range(count)])
hooks = []
def fake_apply(block, next_block, device, stream, pin_memory):
hook = SimpleNamespace(
block=block,
next_block=next_block,
device=device,
stream=stream,
pin_memory=pin_memory,
_prev_hook=None,
offload_layer=Mock(),
)
hooks.append(hook)
return hook
monkeypatch.setattr(
"vllm_omni.diffusion.offloader.layerwise_backend.apply_block_hook",
fake_apply,
)
monkeypatch.setattr(
"vllm_omni.platforms.current_omni_platform.Stream",
Mock(return_value="copy-stream"),
)
encoder = object.__new__(MiniMaxH3Qwen3VLEncoder)
torch.nn.Module.__init__(encoder)
encoder.device_target = torch.device("cpu")
encoder.vision = Stack(2)
encoder.text_model = TextStack(3)
encoder.enable_omni_layerwise_offload(pin_memory=False)
assert len(hooks) == 5
assert all(hook._prev_hook is not None for hook in hooks)
assert all(hook.device == torch.device("cpu") for hook in hooks)
def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
@@ -324,6 +873,82 @@ def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
assert next(video_vae.parameters()).dtype == torch.float32
def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
class FakeRemote(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Linear(1, 1)
monkeypatch.setattr(
vae_module,
"_load_component_config",
lambda _path: {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
},
)
monkeypatch.setattr(
vae_module,
"_load_remote_component",
lambda _path, _config: FakeRemote(),
)
video_vae = vae_module.MiniMaxH3VideoVAE(
"unused",
device=torch.device("meta"),
load_device=torch.device("cpu"),
)
assert next(video_vae.parameters()).device.type == "cpu"
assert video_vae._device_target.type == "meta"
def test_video_vae_uses_snapshot_stager_for_accelerator_target(monkeypatch):
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
class FakeRemote(torch.nn.Module):
def __init__(self):
super().__init__()
self.model = torch.nn.Linear(1, 1)
stager = Mock()
stager_cls = Mock(return_value=stager)
monkeypatch.setattr(
vae_module,
"_load_component_config",
lambda _path: {
"latent_channels": 1,
"latents_mean": [0.0],
"latents_std": [1.0],
},
)
monkeypatch.setattr(
vae_module,
"_load_remote_component",
lambda _path, _config: FakeRemote(),
)
monkeypatch.setattr(vae_module, "PinnedModuleStager", stager_cls)
video_vae = vae_module.MiniMaxH3VideoVAE(
"unused",
device=torch.device("cuda"),
load_device=torch.device("cpu"),
)
video_vae.load_to_device()
video_vae.offload_to_cpu()
stager_cls.assert_called_once_with(
video_vae.remote,
torch.device("cuda"),
pin_memory=True,
)
stager.load.assert_called_once_with()
stager.offload.assert_called_once_with()
def test_video_vae_encode_uses_configured_parallel_tiling():
from vllm_omni.diffusion.models.minimax_h3.vae import (
MiniMaxH3VideoVAE,
@@ -61,6 +61,33 @@ def test_packed_attention_is_a_regional_compile_boundary():
assert getattr(MiniMaxH3Attention._run_packed_attention, "_torchdynamo_disable", False)
def test_h3_fused_rope_matches_reference_and_preserves_unrotated_dims():
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
MiniMaxH3Attention,
)
attention = object.__new__(MiniMaxH3Attention)
nn.Module.__init__(attention)
attention.rope = RotaryEmbedding(is_neox_style=True, half_head_dim=False)
attention.rope._forward_method = attention.rope.forward_native
x = torch.randn(11, 3, 128, dtype=torch.bfloat16)
freqs_half = torch.randn(11, 48)
freqs = torch.cat((freqs_half, freqs_half), dim=-1)
actual = attention._apply_rope(x, freqs)
cos = torch.cos(freqs).to(x.dtype).unsqueeze(1)
sin = torch.sin(freqs).to(x.dtype).unsqueeze(1)
x_rot = x[..., :96]
x1, x2 = x_rot.chunk(2, dim=-1)
expected_rot = x_rot * cos + torch.cat((-x2, x1), dim=-1) * sin
expected = torch.cat((expected_rot, x[..., 96:]), dim=-1)
torch.testing.assert_close(actual, expected, atol=0, rtol=0)
torch.testing.assert_close(actual[..., 96:], x[..., 96:], atol=0, rtol=0)
@pytest.mark.parametrize(
("tp_size", "message"),
[
@@ -135,6 +135,7 @@ def _generate_joint_output(omni, config: QualityTestConfig):
num_frames=config.num_frames,
guidance_scale=config.guidance_scale,
sigmas=config.sigmas,
extra_args={"aspect_ratio": "16:9"},
),
)
main_process_peak_mem = torch.accelerator.max_memory_allocated() / (1024**3)
@@ -27,7 +27,9 @@ from vllm_omni.diffusion.offloader.block_discovery import (
from vllm_omni.diffusion.offloader.distributed_layerwise_backend import (
DistributedLayerwiseOffloadBackend,
DistributedLayerwiseOffloadHook,
PinnedResidentLayerGroup,
)
from vllm_omni.diffusion.offloader.module_residency import PinnedModuleStager
from vllm_omni.diffusion.offloader.offload_plan import OffloadPlan, get_offload_plan
from vllm_omni.platforms import current_omni_platform
@@ -97,6 +99,7 @@ def patched_offload_runtime(monkeypatch):
monkeypatch.setattr(dist_backend_module.current_omni_platform, "Event", DummyEvent)
monkeypatch.setattr(dist_backend_module.current_omni_platform, "current_stream", lambda: DummyStream())
monkeypatch.setattr(dist_backend_module.current_omni_platform, "stream", dummy_stream)
monkeypatch.setattr(dist_backend_module.current_omni_platform, "synchronize", lambda: None)
class TinyBlock(nn.Module):
@@ -308,6 +311,164 @@ class TestDistributedLayerwiseOffloadHook:
assert shard1.numel() == 2
assert torch.equal(shard1, torch.tensor([102.0, 0.0]))
def test_mmap_layout_transform_is_applied_before_sharding(self, dist_group, patched_offload_runtime):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32))
next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0)
next_block.weight.mmap_weight_transform_pending = True
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=None,
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([3.0, 2.0]))
def test_regular_loader_weight_does_not_reapply_mmap_transform(self, dist_group, patched_offload_runtime):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(4, dtype=torch.float32))
next_block.weight.mmap_weight_transform = lambda tensor: tensor.flip(0)
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=None,
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
assert torch.equal(hook.cpu_shards[torch.float32], torch.tensor([0.0, 1.0]))
class TestPinnedResidentLayerGroup:
def test_load_offload_reuses_pinned_master_weights(self, patched_offload_runtime):
blocks = [nn.Linear(2, 2), nn.Linear(2, 2)]
expected = []
for index, block in enumerate(blocks):
block.weight.data.fill_(index + 1)
block.bias.data.fill_(index + 3)
expected.append((block.weight.detach().clone(), block.bias.detach().clone()))
group = PinnedResidentLayerGroup(
blocks,
device=torch.device("cpu"),
copy_stream=DummyStream(),
pin_memory=False,
)
assert all(block.weight.numel() == 0 for block in blocks)
group.load()
for block, (weight, bias) in zip(blocks, expected):
assert torch.equal(block.weight, weight)
assert torch.equal(block.bias, bias)
# Device-side changes must not overwrite the persistent host master.
blocks[0].weight.data.zero_()
group.offload()
assert all(block.weight.numel() == 0 for block in blocks)
group.load()
for block, (weight, bias) in zip(blocks, expected):
assert torch.equal(block.weight, weight)
assert torch.equal(block.bias, bias)
group.offload()
def test_mmap_loader_attrs_survive_to_empty_parameter_replacement(self):
module = nn.Linear(2, 2, bias=False)
def transform(tensor):
return tensor.flip(0)
module.weight.mmap_weight_transform = transform
backend = object.__new__(DistributedLayerwiseOffloadBackend)
backend._remember_mmap_param_attrs(module)
module.to_empty(device="meta")
replacement = nn.Parameter(torch.arange(4).reshape(2, 2).float())
backend._attach_mmap_param_attrs("weight", replacement, module.weight)
assert replacement.mmap_weight_transform is transform
assert replacement.mmap_weight_transform_pending is True
def test_shared_allgather_output_is_narrowed_to_current_block(
self, dist_group, patched_offload_runtime, monkeypatch
):
block = nn.Module()
block.weight = nn.Parameter(torch.zeros(3, dtype=torch.float32))
next_block = nn.Module()
next_block.weight = nn.Parameter(torch.arange(3, dtype=torch.float32))
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=object(),
dp_size=2,
rank=0,
pin_memory=False,
)
hook.initialize_hook(block)
# Simulate unified buffers sized for a larger heterogeneous block.
hook.gpu_buffers = [
{torch.float32: torch.empty(12)},
{torch.float32: torch.empty(12)},
]
hook.gpu_shard_buffers = [
{torch.float32: torch.empty(6)},
{torch.float32: torch.empty(6)},
]
output_sizes = []
def fake_allgather(output, local_shard, *, group):
del group
output_sizes.append(output.numel())
output.zero_()
output[: local_shard.numel()].copy_(local_shard)
monkeypatch.setattr(torch.distributed, "all_gather_into_tensor", fake_allgather)
hook.prefetch_layer(slot=0, non_blocking=False)
assert output_sizes == [4]
class TestPinnedModuleStager:
def test_offload_rebinds_immutable_cpu_master(self, patched_offload_runtime):
module = nn.Linear(2, 2)
expected_weight = module.weight.detach().clone()
expected_bias = module.bias.detach().clone()
stager = PinnedModuleStager(
module,
torch.device("cpu"),
pin_memory=False,
copy_stream=DummyStream(),
)
stager.load()
module.weight.data.zero_()
module.bias.data.zero_()
stager.offload()
assert torch.equal(module.weight, expected_weight)
assert torch.equal(module.bias, expected_bias)
stager.load()
assert torch.equal(module.weight, expected_weight)
assert torch.equal(module.bias, expected_bias)
stager.offload()
class _DummyBlock(nn.Module):
def __init__(self):
@@ -504,6 +665,81 @@ class TestCrossGroupSharedBuffer:
assert not sync_called[0], "Should skip sync-prefetch when slot_group matches (non-contaminated)"
class TestAllGatherOutputSlicing:
"""Regression test: with heterogeneous block sizes (e.g. MiniMax H3's
token_refiner with blocks of ~1231MB and ~239MB), the shared AllGather
output buffers are sized to the *largest* block across all groups.
prefetch_layer must slice the output buffer down to each block's actual
AllGather output size, otherwise output.numel() != dp_size * input.numel()
and all_gather_into_tensor raises during enable()'s first prefetch.
"""
def test_prefetch_slices_shared_output_buffer_to_block_size(self, dist_group, patched_offload_runtime, monkeypatch):
dp_size = 2
# Heterogeneous block sizes: 8 vs 32 elements.
small_block = nn.Module()
small_block.weight = nn.Parameter(torch.arange(8, dtype=torch.float32))
large_block = nn.Module()
large_block.weight = nn.Parameter(torch.arange(100, 132, dtype=torch.float32))
def make_hook(next_block: nn.Module) -> DistributedLayerwiseOffloadHook:
hook = DistributedLayerwiseOffloadHook(
next_block=next_block,
device=torch.device("cpu"),
dp_group=dist.group.WORLD, # non-None -> AllGather path (mocked below)
dp_size=dp_size,
rank=0,
pin_memory=False,
)
current_block = nn.Module()
current_block.weight = nn.Parameter(torch.zeros(4, dtype=torch.float32))
hook.initialize_hook(current_block)
return hook
hook_small = make_hook(small_block)
hook_large = make_hook(large_block)
hooks = [hook_small, hook_large]
# Assign max-sized shared buffers exactly as backend.enable() does.
shared_buffers = DistributedLayerwiseOffloadBackend._allocate_shared_buffers(hooks)
shared_shard_buffers = DistributedLayerwiseOffloadBackend._allocate_shared_shard_buffers(hooks)
for hook in hooks:
hook.gpu_buffers = shared_buffers
hook.gpu_shard_buffers = shared_shard_buffers
# Shared output buffer must be sized to the largest block (32).
assert shared_buffers[0][torch.float32].numel() == 32
# Fake AllGather that enforces the real torch size contract and
# emulates reconstruction of the full block from all ranks' shards.
full_values = {
8: torch.arange(8, dtype=torch.float32),
32: torch.arange(100, 132, dtype=torch.float32),
}
ag_output_numels: list[int] = []
def fake_all_gather(output, input, group=None):
assert output.numel() == dp_size * input.numel(), (
f"AllGather contract violated: output.numel()={output.numel()} "
f"!= {dp_size} * input.numel()={input.numel()}"
)
ag_output_numels.append(output.numel())
output.copy_(full_values[output.numel()])
monkeypatch.setattr(dist, "all_gather_into_tensor", fake_all_gather)
# Small block: output must be sliced to dp_size * shard = 8, not 32.
hook_small.prefetch_layer(slot=0, non_blocking=False)
assert ag_output_numels[-1] == 8
assert torch.equal(small_block.weight, full_values[8])
# Large block: uses the full max-sized buffer.
hook_large.prefetch_layer(slot=1, non_blocking=False)
assert ag_output_numels[-1] == 32
assert torch.equal(large_block.weight, full_values[32])
class TestOffloadPlan:
"""Test declarative OffloadPlan metadata."""
@@ -517,6 +753,7 @@ class TestOffloadPlan:
plan = OffloadPlan(
block_attrs={"transformer": ("gen_layers",)},
offload_submodules={"context_encoder": "layers"},
resident_dit_paths=frozenset({"transformer"}),
)
class PipelineWithPlan(nn.Module):
@@ -527,12 +764,14 @@ class TestOffloadPlan:
assert result is plan
assert result.block_attrs == {"transformer": ("gen_layers",)}
assert result.offload_submodules == {"context_encoder": "layers"}
assert result.resident_dit_paths == frozenset({"transformer"})
def test_offload_plan_defaults_to_empty(self):
"""OffloadPlan with no arguments should have empty dicts."""
plan = OffloadPlan()
assert plan.block_attrs == {}
assert plan.offload_submodules == {}
assert plan.resident_dit_paths == frozenset()
def test_offload_plan_is_frozen(self):
"""OffloadPlan should be immutable (frozen=True)."""
@@ -540,6 +779,50 @@ class TestOffloadPlan:
with pytest.raises(Exception):
plan.block_attrs = {"x": ("y",)} # type: ignore
def test_all_resident_dit_still_prepares_planned_submodules(self, patched_offload_runtime):
class TokenRefiner(nn.Module):
def __init__(self):
super().__init__()
self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)])
class Transformer(nn.Module):
_layerwise_offload_blocks_attrs = ["blocks"]
def __init__(self):
super().__init__()
self.blocks = nn.ModuleList([nn.Linear(2, 2), nn.Linear(2, 2)])
self.token_refiner = TokenRefiner()
class Pipeline(nn.Module):
_offload_plan = OffloadPlan(
offload_submodules={"token_refiner": "blocks"},
resident_dit_paths=frozenset({"transformer"}),
)
def __init__(self):
super().__init__()
self.transformer = Transformer()
pipeline = Pipeline()
backend = DistributedLayerwiseOffloadBackend(
OffloadConfig(
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
pin_cpu_memory=False,
dlo_use_allgather=False,
dlo_resident_layers=2,
),
torch.device("cpu"),
)
backend.enable(pipeline)
assert len(backend._resident_blocks) == 2
assert len(backend._all_hook_groups) == 1
assert len(backend._all_hook_groups[0]) == 2
assert backend.enabled
backend.disable()
class TestMmapValidation:
"""Tests for mmap loader validation: TP rejection, strict check, validate_loaded_weights."""
@@ -746,7 +1029,7 @@ class TestConfigValidation:
OffloadConfig.from_od_config(FakeODConfig())
def test_hsdp_without_allgather_allowed(self):
"""HSDP + DLO + no-AllGather should be allowed (full weights per rank)."""
"""HSDP + DLO + no-AllGather uses standard-loader rank-local weights."""
from vllm_omni.diffusion.offloader.base import OffloadConfig
class FakePC:
@@ -768,12 +1051,33 @@ class TestConfigValidation:
enable_layerwise_offload = False
enable_distributed_layerwise_offload = True
dlo_use_allgather = False # no AllGather → should be allowed
dlo_resident_layers = 20
pin_cpu_memory = True
parallel_config = FakePC()
model = "/fake/path"
config = OffloadConfig.from_od_config(FakeODConfig())
assert config.dp_size == 1 # forced to 1 when no AllGather
assert config.dlo_resident_layers == 20
def test_resident_layers_with_allgather_rejected(self):
class FakePC:
data_parallel_size = 2
use_hsdp = False
sequence_parallel_size = 1
class FakeODConfig:
enable_cpu_offload = False
enable_layerwise_offload = False
enable_distributed_layerwise_offload = True
dlo_use_allgather = True
dlo_resident_layers = 20
pin_cpu_memory = True
parallel_config = FakePC()
model = "/fake/path"
with pytest.raises(ValueError, match="requires --dlo-no-use-allgather"):
OffloadConfig.from_od_config(FakeODConfig())
def test_num_inference_steps_none_rejected(self):
"""DP multi-concurrency should reject None num_inference_steps."""
@@ -147,6 +147,130 @@ def test_get_npu_quant_method(mocker: MockerFixture, monkeypatch: pytest.MonkeyP
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:
@pytest.fixture
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
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():
additional_config = {"torchair_graph_config": {"enabled": True}}
od = _roundtrip_diffusion_config(model="x", additional_config=additional_config)
@@ -247,6 +247,56 @@ class TestRequestModeDispatch:
results = {ro.request_id: ro.result.error for ro in out.runner_outputs}
assert results == {request_id: f"result_for_{request_id}" for request_id in request_ids}
def test_dlo_dp_routes_multiple_requests_without_pipeline_batching(self):
executor, _, _ = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
enable_distributed_layerwise_offload=True,
dlo_use_allgather=True,
)
executor.execute_request = Mock(return_value="dlo-dp")
executor.collective_rpc = Mock()
scheduler_output = _make_sched_output("A", "B")
result = executor.execute_batch(scheduler_output)
assert result == "dlo-dp"
executor.execute_request.assert_called_once_with(scheduler_output)
executor.collective_rpc.assert_not_called()
def test_dlo_dp_multi_rank_reply_uses_synchronous_rpc_collection(self):
executor, req_q, res_q = _make_executor(num_gpus=2)
executor.od_config = SimpleNamespace(
step_execution=False,
parallel_config=SimpleNamespace(data_parallel_size=2),
)
executor._sync_result_buffer = res_q
def worker():
request = req_q.get(timeout=2)
assert "rpc_id" not in request
wave_id = request["wave_id"]
for dp_rank in range(2):
res_q.put(
{
"dp_rank": dp_rank,
"output": _tagged_output(str(dp_rank)),
"wave_id": wave_id,
}
)
thread = threading.Thread(target=worker, daemon=True)
thread.start()
results = executor.collective_rpc(
"execute_model",
unique_reply_rank=None,
exec_all_ranks=True,
)
thread.join(timeout=2)
assert [result.error for result in results] == ["0", "1"]
# ───────────────── concurrent collective RPC ─────────────────
+18
View File
@@ -128,6 +128,24 @@ def test_qwen3_tts_codec_frame_rate_patching():
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():
"""from_cli_args should pick up stage_configs_path from 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
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])
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)
@@ -240,6 +240,28 @@ def test_serve_cli_accepts_ulysses_mode():
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():
"""Ensure diffusion serve CLI exposes the profiler switch."""
parser = TrackingArgumentParser()
@@ -262,6 +284,36 @@ def test_serve_cli_accepts_diffusion_pipeline_profiler_flag():
assert stage_cfg["engine_args"]["enable_diffusion_pipeline_profiler"] is True
def test_serve_cli_forwards_distributed_offload_residency():
"""Ensure the two-GPU DLO placement controls reach the diffusion stage."""
parser = TrackingArgumentParser()
subparsers = parser.add_subparsers(dest="command")
OmniServeCommand().subparser_init(subparsers)
args = parser.parse_args(
[
"serve",
"MiniMaxAI/MiniMax-H3",
"--omni",
"--enable-distributed-layerwise-offload",
"--dlo-no-use-allgather",
"--dlo-resident-layers",
"20",
]
)
explicit_kwargs = args.get_explicit_kwargs_dict()
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0]
engine_args = stage_cfg["engine_args"]
assert args.enable_distributed_layerwise_offload is True
assert args.dlo_use_allgather is False
assert args.dlo_resident_layers == 20
assert engine_args["enable_distributed_layerwise_offload"] is True
assert engine_args["dlo_use_allgather"] is False
assert engine_args["dlo_resident_layers"] == 20
def test_serve_cli_accepts_diffusion_compile_controls():
"""Ensure both compile controls reach the diffusion stage."""
parser = TrackingArgumentParser()
@@ -53,6 +53,11 @@ def download_backend(monkeypatch: pytest.MonkeyPatch):
"download_weights_from_hf_specific",
lambda **_kwargs: picked.append("huggingface"),
)
monkeypatch.setattr(
omni_base,
"file_or_path_exists",
lambda *_args, **_kwargs: False,
)
def run() -> str:
picked.clear()
@@ -82,3 +87,14 @@ def test_unset_defaults_to_huggingface(monkeypatch, download_backend):
monkeypatch.delenv("VLLM_USE_MODELSCOPE", raising=False)
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
"""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
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):
@@ -0,0 +1,67 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Regression tests for MiniCPM-o 4.5 audio chunk attention masks."""
from __future__ import annotations
import pytest
import torch
from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import (
MiniCPMO45OmniLLMForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _reference_chunk_mask(
size: int,
chunk_size: int,
num_left_chunks: int,
num_lookhead: int,
) -> torch.Tensor:
"""Preserve the original row-by-row implementation as a test oracle."""
mask = torch.zeros(size, size, dtype=torch.bool)
for row in range(size):
if num_left_chunks < 0:
start = 0
else:
start = max(
(row // chunk_size - num_left_chunks) * chunk_size,
0,
)
end = min(
(row // chunk_size + 1) * chunk_size + num_lookhead,
size,
)
mask[row, start:end] = True
return mask
@pytest.mark.parametrize("size", [0, 1, 49, 50, 51, 1500])
@pytest.mark.parametrize("chunk_size", [1, 17, 50])
@pytest.mark.parametrize("num_left_chunks", [-1, 0, 1, 3])
@pytest.mark.parametrize("num_lookhead", [0, 1, 7])
def test_subsequent_chunk_mask_matches_row_by_row_reference(
size: int,
chunk_size: int,
num_left_chunks: int,
num_lookhead: int,
) -> None:
actual = MiniCPMO45OmniLLMForConditionalGeneration.subsequent_chunk_mask(
None,
size=size,
chunk_size=chunk_size,
num_left_chunks=num_left_chunks,
num_lookhead=num_lookhead,
)
expected = _reference_chunk_mask(
size,
chunk_size,
num_left_chunks,
num_lookhead,
)
assert actual.dtype == torch.bool
assert actual.shape == (size, size)
assert torch.equal(actual, expected)
@@ -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",
"LTX2I2VDMD2Pipeline",
"MiniMaxH3Pipeline",
"MiniMaxH3ModularPipeline",
"StableAudioPipeline",
"WanImageToVideoPipeline",
"WanS2VPipeline",
+13 -2
View File
@@ -17,6 +17,8 @@ import vllm_omni.model_executor.models as me_models
logger = init_logger(__name__)
_QWEN3_TTS_TASK_TYPES = frozenset({"CustomVoice", "VoiceDesign", "Base"})
class OmniModelArchConfigConvertor(ModelArchConfigConvertorBase):
"""Config convertor for Omni multi-stage models.
@@ -101,8 +103,8 @@ class OmniModelConfig(ModelConfig):
"audio", "latents"). If None, output type is inferred.
stage_connector_config: Stage connector configuration dictionary.
Contains "name" (connector name), "extra" (extra connector config).
task_type: Default task type for TTS models (CustomVoice, VoiceDesign, or Base).
If not specified, will be inferred from model path.
task_type: Model-defined startup task type. Each model validates its
supported values and applies the corresponding behavior.
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)
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):
"""Patches the value of `position_id_per_seconds` in Qwen3's
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._maybe_override_text_config()
omni_cfg._validate_startup_task_type()
if omni_cfg.hf_config is not None:
omni_cfg.hf_config.architectures = omni_cfg.architectures
+269 -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
_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
@@ -55,6 +78,21 @@ class _QuantizationEngineOverrides(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
enable_sleep_mode: bool
subtalker_sampling_params: dict[str, Any]
@@ -63,6 +101,7 @@ class _ModelEngineOverrides(TypedDict, total=False):
task_type: str
codec_frame_rate_hz: float
enforce_eager: bool
max_cudagraph_capture_size: int
enable_flashinfer_autotune: bool
compilation_config: dict[str, Any]
enable_multithread_weight_load: bool
@@ -71,6 +110,9 @@ class _ModelEngineOverrides(TypedDict, total=False):
class _LoadEngineOverrides(TypedDict, total=False):
tokenizer: str
download_dir: str
skip_tokenizer_init: bool
load_format: str
tokenizer_mode: str
config_format: str
@@ -78,6 +120,7 @@ class _LoadEngineOverrides(TypedDict, total=False):
class _CacheEngineOverrides(TypedDict, total=False):
kv_cache_memory_bytes: int
gpu_memory_utilization: float
enable_prefix_caching: bool
disable_hybrid_kv_cache_manager: bool
@@ -93,6 +136,8 @@ class _SchedulerEngineOverrides(TypedDict, total=False):
class _RuntimeEngineOverrides(TypedDict, total=False):
distributed_executor_backend: Any
worker_cls: str
devices: str
num_replicas: int
env: dict[str, Any]
@@ -113,6 +158,7 @@ class _ParallelConfigEngineOverrides(TypedDict, total=False):
ulysses_mode: str
cfg_parallel_size: int
vae_patch_parallel_size: int
vae_parallel_mode: str
text_encoder_tp_size: int
use_hsdp: bool
mask_sp_padding: bool
@@ -125,6 +171,10 @@ class _ParallelEngineOverrides(_ParallelConfigEngineOverrides, total=False):
parallel_config: _ParallelConfigEngineOverrides | Mapping[str, Any]
class _ConnectorEngineOverrides(TypedDict, total=False):
omni_kv_config: dict[str, Any]
@dataclass(frozen=True)
class _StageEngineValues:
"""Typed projections of legacy flat per-stage ``yaml_engine_args``."""
@@ -134,6 +184,7 @@ class _StageEngineValues:
load: _LoadEngineOverrides
cache: _CacheEngineOverrides
scheduler: _SchedulerEngineOverrides
connector: _ConnectorEngineOverrides
runtime: _RuntimeEngineOverrides
parallel: _ParallelEngineOverrides
diffusion: _DiffusionEngineOverrides
@@ -255,8 +306,21 @@ def _get_deploy_config(
@config
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)
duplex_max_sessions: int = Field(default=1, ge=1)
enable_sleep_mode: bool = False
@@ -267,6 +331,7 @@ class OmniStageModelConfig:
task_type: str | None = None
codec_frame_rate_hz: float | None = None
enforce_eager: bool = False
max_cudagraph_capture_size: int | None = Field(default=None, ge=0)
enable_flashinfer_autotune: bool | None = None
compilation_config: dict[str, Any] | None = None
enable_multithread_weight_load: bool = True
@@ -281,8 +346,11 @@ class OmniStageModelConfig:
@config
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"
tokenizer_mode: str = "auto"
config_format: str | None = None
@@ -297,9 +365,11 @@ class OmniStageCacheConfig:
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)
enable_prefix_caching: bool = False
disable_hybrid_kv_cache_manager: bool = False
kv_cache_memory_bytes: int | None = Field(default=None, ge=0)
# None preserves backend-owned defaults; explicit values still project.
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)
@@ -307,14 +377,18 @@ class OmniStageCacheConfig:
class OmniStageSchedulerConfig:
"""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_model_len: int | None = Field(default=None, ge=-1)
enable_chunked_prefill: bool = False
async_scheduling: bool = True
enable_chunked_prefill: bool | None = None
async_scheduling: bool | None = 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(
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
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(
default_factory=lambda: {
"name": "SharedMemoryConnector",
@@ -336,8 +412,10 @@ class OmniStageConnectorConfig:
@config
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
num_replicas: int = Field(default=1, ge=1)
env: dict[str, Any] | None = None
@@ -448,7 +526,12 @@ class _DiffusionConfigProjection:
stage_id: int = 0
model: 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
task_type: str | None = None
dtype: Any = "auto"
trust_remote_code: bool = False
revision: str | None = None
@@ -456,6 +539,7 @@ class _DiffusionConfigProjection:
dist_timeout: int | None = None
nccl_port: int | None = None
master_port: int | None = None
scheduler_port: int | None = None
host: str | None = None
port: int | None = None
model_config: dict[str, Any] = field(default_factory=dict)
@@ -467,6 +551,7 @@ class _DiffusionConfigProjection:
enable_cache_dit_summary: bool = False
enable_prompt_embed_cache: bool = False
prompt_embed_cache_size: int = Field(default=32, ge=1)
enable_session_state_manager: bool = False
diffusion_load_format: str = "default"
diffusers_load_kwargs: dict[str, Any] = field(default_factory=dict)
diffusers_call_kwargs: dict[str, Any] = field(default_factory=dict)
@@ -479,6 +564,7 @@ class _DiffusionConfigProjection:
enable_layerwise_offload: bool = False
enable_distributed_layerwise_offload: bool = False
dlo_use_allgather: bool = True
dlo_resident_layers: int = Field(default=0, ge=0)
pin_cpu_memory: bool = True
diffusion_compile_granularity: Literal["regional", "full"] = "regional"
diffusion_compile_dynamic: bool = Field(default=True, strict=True)
@@ -501,6 +587,7 @@ class _DiffusionConfigProjection:
step_execution: bool = False
supports_multimodal_inputs: bool = False
max_multimodal_image_inputs: int | None = None
supports_mixed_reference_inputs: bool = False
model_paths: dict[str, str] = field(default_factory=dict)
model_loaded: dict[str, bool] = field(
default_factory=lambda: {
@@ -678,6 +765,7 @@ _DIFFUSION_SHARED_CONFIG_FIELDS = frozenset(
"stage_id",
"model",
"model_arch",
"task_type",
"dtype",
"trust_remote_code",
"revision",
@@ -693,6 +781,7 @@ _DIFFUSION_RUNTIME_CONFIG_FIELDS = frozenset(
"port",
"nccl_port",
"master_port",
"scheduler_port",
"worker_extension_cls",
"enable_stage_verification",
"prompt_file_path",
@@ -740,21 +829,85 @@ _MODEL_ENGINE_FIELDS = frozenset(_ModelEngineOverrides.__annotations__)
_LOAD_ENGINE_FIELDS = frozenset(_LoadEngineOverrides.__annotations__)
_CACHE_ENGINE_FIELDS = frozenset(_CacheEngineOverrides.__annotations__)
_SCHEDULER_ENGINE_FIELDS = frozenset(_SchedulerEngineOverrides.__annotations__)
_CONNECTOR_ENGINE_FIELDS = frozenset(_ConnectorEngineOverrides.__annotations__)
_RUNTIME_ENGINE_FIELDS = frozenset(_RuntimeEngineOverrides.__annotations__)
_PARALLEL_CONFIG_ENGINE_FIELDS = frozenset(_ParallelConfigEngineOverrides.__annotations__)
_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]:
# Lazy import avoids vllm_omni.config -> omni_config -> engine.arg_utils ->
# 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(_STAGE_DEPLOY_ENGINE_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]:
@@ -774,17 +927,33 @@ def _stage_engine_overrides(stage_deploy: StageDeployConfig | None) -> dict[str,
value = getattr(stage_deploy, name)
if value is not None:
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
def _stage_engine_values(
stage_deploy: StageDeployConfig | None,
topology: StagePipelineConfig,
stage_cli_overrides: Mapping[str, Any] | None = None,
) -> _StageEngineValues:
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:
engine.update(_copy_value(stage_cli_overrides))
_validate_stage_engine_override_ownership(
topology.stage_id,
topology.execution_type,
engine,
)
return _StageEngineValues(
quantization=cast(
_QuantizationEngineOverrides,
@@ -797,6 +966,10 @@ def _stage_engine_values(
_SchedulerEngineOverrides,
_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)),
parallel=cast(_ParallelEngineOverrides, _select_engine_overrides(engine, _PARALLEL_ENGINE_FIELDS)),
diffusion=_DiffusionEngineOverrides.from_engine(engine),
@@ -892,9 +1065,10 @@ class BaseVllmOmniStageConfig:
@property
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.scheduler_config.async_scheduling,
True if async_scheduling is None else async_scheduling,
)
@property
@@ -956,11 +1130,14 @@ StageConfigType: TypeAlias = VllmOmniARStageConfig | VllmOmniGenerationStageConf
def _build_common_stage_config_kwargs(
pipeline: PipelineConfig,
deploy: DeployConfig,
topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None,
engine: _StageEngineValues,
parallel_config_cls: type[OmniStageParallelConfig] = OmniStageParallelConfig,
*,
model: str | None,
) -> tuple[dict[str, Any], str | None, str | None]:
input_proc, next_stage_proc = _select_processor_funcs(topology, bool(deploy.async_chunk))
quantization_config = _build_quantization_config(deploy, engine.quantization)
@@ -970,16 +1147,36 @@ def _build_common_stage_config_kwargs(
{
"stage_pipeline_config": topology,
"model_config": _build_model_config(
pipeline,
deploy,
topology,
stage_deploy,
engine.model,
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,
"quantization_config": _copy_value(quantization_config),
},
@@ -1008,10 +1205,12 @@ def _build_ar_stage_config(
model: str | None,
) -> VllmOmniARStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy,
topology,
stage_deploy,
engine,
model=model,
)
return cast(
VllmOmniARStageConfig,
@@ -1033,10 +1232,12 @@ def _build_generation_stage_config(
model: str | None,
) -> VllmOmniGenerationStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy,
topology,
stage_deploy,
engine,
model=model,
)
return cast(
VllmOmniGenerationStageConfig,
@@ -1058,18 +1259,20 @@ def _build_diffusion_stage_config(
model: str | None,
) -> VllmOmniDiffusionStageConfig:
common_kwargs, input_proc, next_stage_proc = _build_common_stage_config_kwargs(
pipeline,
deploy,
topology,
stage_deploy,
engine,
OmniStageDiffusionParallelConfig,
model=model,
)
common_kwargs["diffusion_config"] = _build_diffusion_config_projection(
pipeline,
deploy,
topology,
engine.diffusion,
model=model,
model=common_kwargs["model_config"].model,
quantization_config=common_kwargs["quantization_config"],
)
return cast(
@@ -1124,14 +1327,28 @@ def _build_quantization_config(
def _build_model_config(
pipeline: PipelineConfig,
deploy: DeployConfig,
topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None,
engine: _ModelEngineOverrides,
*,
duplex_max_sessions: int,
model: str | None,
) -> OmniStageModelConfig:
default_sampling_params = _stage_sampling_params(stage_deploy, topology)
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:
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:
@@ -1145,45 +1362,67 @@ def _build_model_config(
)
def _build_load_config(engine: _LoadEngineOverrides) -> OmniStageLoadConfig:
return OmniStageLoadConfig(**_config_kwargs(engine))
def _build_load_config(
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(
deploy: DeployConfig,
engine: _CacheEngineOverrides,
execution_type: StageExecutionType,
) -> OmniStageCacheConfig:
kwargs = _config_kwargs(engine)
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)
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)
def _build_scheduler_config(
deploy: DeployConfig,
engine: _SchedulerEngineOverrides,
execution_type: StageExecutionType,
) -> OmniStageSchedulerConfig:
kwargs = _config_kwargs(engine)
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)
if "async_scheduling" not in kwargs and execution_type == StageExecutionType.LLM_AR:
kwargs["async_scheduling"] = True
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
input_connectors = stage_deploy.input_connectors if stage_deploy is not None else None
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,
input_connectors=_copy_value(input_connectors) if input_connectors else None,
)
def _build_runtime_config(
deploy: DeployConfig,
stage_deploy: StageDeployConfig | None,
engine: _RuntimeEngineOverrides,
parallel_config: OmniStageParallelConfig,
) -> OmniStageRuntimeConfig:
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:
kwargs["devices"] = _copy_value(stage_deploy.devices)
if "num_replicas" not in kwargs and stage_deploy is not None:
@@ -1233,13 +1472,15 @@ def _build_diffusion_config_projection(
topology.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:
diffusion_kwargs["dtype"] = _copy_value(deploy.dtype)
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)
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)
if model is not None:
if "model" not in diffusion_kwargs and model is not None:
diffusion_kwargs["model"] = model
if quantization_config is not None:
diffusion_kwargs["quantization_config"] = _copy_value(quantization_config)
@@ -1301,6 +1542,7 @@ class VllmOmniConfig:
deploy_by_id.get(topology.stage_id),
_stage_engine_values(
deploy_by_id.get(topology.stage_id),
topology,
_stage_cli_overrides(topology.stage_id, cli_overrides),
),
model=model,
+3
View File
@@ -413,6 +413,9 @@ class StageDeployConfig:
enable_cpu_offload: bool | None = None
enable_layerwise_offload: bool | None = None
enable_distributed_layerwise_offload: bool | None = None
dlo_use_allgather: bool | None = None
dlo_resident_layers: int | None = None
# Diffusion-specific debug and observability knobs.
enable_diffusion_pipeline_profiler: bool | None = None
@@ -15,6 +15,25 @@ class AttentionBackend(ABC):
accept_output_buffer: bool = False
supports_piecewise_spans: bool = False
# The backend can represent a contiguous valid K/V prefix by slicing the
# tensors instead of materializing a padding mask. Models may use this to
# avoid a slower masked-attention plan when tail padding is not semantic.
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
def supports_attention_mask(cls) -> bool:
@@ -59,6 +78,23 @@ class QueryRange:
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
class AttentionMetadata:
attn_mask: torch.Tensor | None = None
@@ -83,12 +119,18 @@ class AttentionMetadata:
# variable-length query/key sequences for FlashAttention.
# "max_seqlen_q" / "max_seqlen_k": maximum sequence lengths paired with
# the packed cu_seqlens tensors.
# "valid_kv_length": int — contiguous valid K/V prefix length for a
# backend that advertises supports_prefix_kv_slicing.
# Piecewise attention metadata (mixed causal/full masks).
# full_attn_spans: per-sample [start, end) spans in global coordinates using full attention.
full_attn_spans: list[list[tuple[int, int]]] | 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)
@@ -17,6 +17,7 @@ logger = init_logger(__name__)
class CuDNNAttentionBackend(AttentionBackend):
accept_output_buffer: bool = True
supports_prefix_kv_slicing: bool = True
@classmethod
def supports_attention_mask(cls) -> bool:
@@ -61,7 +62,26 @@ class CuDNNAttentionImpl(AttentionImpl):
) -> torch.Tensor:
attention_mask = None
if attn_metadata:
attention_mask = _maybe_reshape_attn_mask(query, key, attn_metadata.attn_mask, mask_mode="broadcast_k")
valid_kv_length = attn_metadata.extra.get("valid_kv_length")
if attn_metadata.attn_mask is None and isinstance(valid_kv_length, int):
if not 0 < valid_kv_length <= key.shape[1]:
raise ValueError(
"valid_kv_length must be within the K/V sequence, "
f"got {valid_kv_length} for length {key.shape[1]}"
)
# A contiguous valid prefix is mathematically equivalent to a
# broadcast key-padding mask. Slicing K/V keeps Q/output in the
# checkpoint's aligned layout while letting cuDNN select its
# much faster mask-free FMHA plan.
key = key[:, :valid_kv_length]
value = value[:, :valid_kv_length]
else:
attention_mask = _maybe_reshape_attn_mask(
query,
key,
attn_metadata.attn_mask,
mask_mode="broadcast_k",
)
enable_gqa = query.shape[2] != key.shape[2]
query, key, value = (x.permute(0, 2, 1, 3) for x in (query, key, value))
@@ -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_3_HUB = "vllm_omni.diffusion.attention.backends.flash_attn_hub.FlashAttention3HubBackend"
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:
"""Get the class path for this backend (respects overrides).
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more