Compare commits

...
Author SHA1 Message Date
Hongsheng Liu ac67e8874d Update docs/design/index.md
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-06 12:32:40 +08:00
Hongsheng Liu dfe6e13cfc Update docs/.nav.yml
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-06 12:27:54 +08:00
Hongsheng Liu 9d80efcbe5 docs: keep design reorg arrangement-only
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-06 12:27:18 +08:00
Hongsheng Liu ddbbfd29e5 docs: reorganize design navigation
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-08-06 12:27:18 +08:00
hesiruandamy-why-3459 9235b0ae65 Optimize MiniCPM-o 4.5 Whisper chunk-attention mask construction (#5382)
Signed-off-by: a-kaa <siruhe666@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-06 09:48:46 +08:00
SYLARandlishunyang12 1c2a81f6d8 feat(minimax-h3): enable 2x RTX 4090/5090 with DLO (#5764)
Signed-off-by: lishunyang12 <lishunyang03@gmail.com>
Signed-off-by: lishunyang12 <lishunyang12@163.com>
Co-authored-by: lishunyang12 <lishunyang03@gmail.com>
2026-08-06 09:02:29 +08:00
zhengjiaandCursor b4581b293b [Doc] Add Cosmos3-Nano ROCm recipe (1x MI350X) (#5634)
Signed-off-by: zjli2013 <leezhengjiang@126.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:50:15 +08:00
bjf-frz 9f978923f2 [docs] consolidate diffusion execution modes (#5599)
Signed-off-by: bjf-frz <frz123db@gmail.com>
2026-08-05 22:41:18 +08:00
Nick Cao 589afcef6c Add @NickCao to CODEOWNERS (#5807)
Signed-off-by: Nick Cao <ncao@redhat.com>
2026-08-05 21:50:48 +08:00
Zeyu Huang | 黃澤宇 556c5f88a0 [skip ci][Doc] add comfyui hint for Minimal-H3 (#5785)
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
2026-08-05 11:12:06 +00:00
R0CKSTAR 2eada79b99 [Hardware][MUSA] Make MiniMax H3 conditioned VAE RNG device-aware (#5703)
Signed-off-by: Xiaodong Ye <xiaodong.ye@mthreads.com>
2026-08-05 03:11:46 -07:00
wangyu 7fb62db5c2 [CI]For NPU CI, Nest perf baselines by hardware label (H100/A3) (#5402)
Signed-off-by: wangyu <410167048@qq.com>
2026-08-05 17:35:41 +08:00
ooooooye 6510de344e [Bugfix][Diffusion] Fix DLO AllGather size mismatch for models with h… (#5802)
Signed-off-by: brandneway <gyuan4892@gmail.com>
2026-08-05 09:32:41 +00:00
Wallbreazzz 740cb35a59 [BugFix] Fix MOSS-TTS codec v1/v2 detection and align vendored tokenizer with upstream (#5635)
Signed-off-by: Wallbreazzz <110282866+Wallbreazzz@users.noreply.github.com>
2026-08-05 08:24:32 +00:00
WeiQing Chen ae7bee0c43 [Feature] Align MiniMax H3 official input matrix (#5752)
Signed-off-by: david6666666 <530634352@qq.com>
2026-08-05 07:12:01 +00:00
GXIN 6a604be610 [Bugfix][NPU] Fallback to BSND RoPE when Qwen3-TTS short-seq BNSD li… (#5608)
Signed-off-by: XIN GAO <1037396230@qq.com>
2026-08-05 13:02:47 +08:00
f201b7171f [Perf][MiniCPM-o] TensorRT acceleration for the Code2Wav vocoder (DiT estimator + campplus) (#5638)
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-08-04 20:58:19 -07:00
wangyu 0a7ac4c67e [CI/Build] Speed up omni sleep-mode entrypoint tests with shared engines (#5713)
Signed-off-by: wangyu <410167048@qq.com>
2026-08-05 03:26:23 +00:00
Yueqian LinandSy0307 e788ef6e76 [Feature] PersonaPlex (Moshi-based full-duplex S2S): native vLLM port + duplex serving (#4771)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Signed-off-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
Co-authored-by: Sy0307 <24841857+Sy0307@users.noreply.github.com>
2026-08-04 20:10:33 -07:00
Shenglei FuandClaude f24a6165ff [Test] Prefix-cache passthrough test coverage prerequisite for W2 (#4855) (#5310)
Signed-off-by: Shenglei Fu <sfu@confluent.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-04 20:03:57 -07:00
Joshna-Medisetty bfaaa3c2c3 [Bugfix][XPU][Tests] Add tests/e2e/accuracy/__init__.py to fix pytest… (#5780)
Signed-off-by: Joshna Medisetty <joshna.medisetty@intel.com>
2026-08-05 07:14:22 +08:00
Atharv Arun Chivate c9f2e5ad94 fix(bagel): correct CFG position ID concatenation for multimodal RoPE (#5775)
Signed-off-by: atharv0o <missionblood2@gmail.com>
2026-08-05 01:53:59 +08:00
Nick Cao fbea788668 [Refactor] Use vLLM video loader for video reference decoding (#5085)
Signed-off-by: Nick Cao <ncao@redhat.com>
2026-08-05 01:49:41 +08:00
Gao Han 78c144f3a8 [CI failed] Revert "[Perf][Qwen3-TTS] Fuse QKV and gate_up projections in code predictor" (#5777) 2026-08-04 15:21:38 +00:00
NATURE 1c4aca7f6e [Bugfix][Hunyuan/Bagel]Avoid payload connector for KV-only senders (#5744)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
2026-08-04 14:40:27 +00:00
175 changed files with 17278 additions and 2951 deletions

No files matched your search

+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
+42 -26
View File
@@ -71,8 +71,7 @@ nav:
- CPU Offloading: user_guide/diffusion/cpu_offload_diffusion.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
- Online Quantization: user_guide/quantization/online.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
- Ray-based Execution: design/feature/ray_based_execution.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: user_guide/quantization/overview.md
- Cache-DiT: design/feature/cache_dit.md
- TeaCache: design/feature/teacache.md
- Diffusion Continuous Batching: design/feature/diffusion_continuous_batching.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`
@@ -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. Prefer hardware-nested form: `{"H100": {"throughput_qps": …}, "A3": {…}}` (selected via runtime hardware label). Legacy flat `{metric: threshold}` still works. Per metric: scalar, list (sweep step), or object keyed by concurrency / QPS.
@@ -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
@@ -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)
+51 -5
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
### Runtime and stage execution
- [Disaggregated Inference](feature/disaggregated_inference.md)
- [Ray-based Execution](feature/ray_based_execution.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)
- [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)
- [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md)
## Infrastructure Design Documents
### 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 Overview](../user_guide/quantization/overview.md)
- [Cache-DiT](feature/cache_dit.md)
- [TeaCache](feature/teacache.md)
- [Diffusion Continuous Batching](feature/diffusion_continuous_batching.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)
+1 -2
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).
---
+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)
+1 -1
View File
@@ -44,7 +44,7 @@ th {
| `LTX2Pipeline` | LTX-2 / LTX-2.3 one-stage T2V and I2V | `Lightricks/LTX-2`, `diffusers/LTX-2.3-Diffusers` | ✅︎ | ✅︎ | | |
| `LTX2DistilledPipeline` | LTX-2 distilled two-stage T2V and I2V | `rootonchair/LTX-2-19b-distilled` | ✅︎ | ✅︎ | | |
| `LingBotVideoPipeline` | LingBot-Video dense and MoE T2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, FL2VA, and Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | | | |
| `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | | | |
| `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `HeliosPipeline`, `HeliosPyramidPipeline` | Helios | `BestWishYsh/Helios-Base`, `BestWishYsh/Helios-Mid`, `BestWishYsh/Helios-Distilled` | ✅︎ | ✅︎ | ✅︎ | |
| `MagiHumanPipeline` | MagiHuman | `SII-GAIR/daVinci-MagiHuman-Base-1080p` | ✅︎ | ✅︎ | | |
@@ -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).
+7 -8
View File
@@ -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
@@ -160,7 +159,7 @@ The following tables show which models support each feature:
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Cosmos3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ |
| **MiniMax-H3** | ❌ | ✅ | ✅ | ❌ | ✅ (DiT/TE) | ❌ | ✅ | ✅ | ✅ (tile) | ✅ (DiT) | ❌ |
**Frame Interpolation Support**
@@ -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
@@ -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
@@ -0,0 +1,77 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Offline PersonaPlex inference through the vllm-omni fullduplex backend.
This is the runnable end-to-end proof for the integration: it drives a user WAV
through :class:`PersonaPlexEngine` + :class:`PersonaPlexSession` (our wrappers
around the Moshi/Mimi lockstep loop) and writes the agent's audio + inner-monologue
text. It mirrors ``python -m moshi.offline`` but goes through our ``FrameStepper``
seam, so a green run validates that the session/adapter plumbing is faithful.
Prereqs: HF access to ``nvidia/personaplex-7b-v1`` (``export HF_TOKEN=...``). The
stack is moshi-free (native temporal + depformer + streaming Mimi via
``transformers``); decoding is greedy.
Example:
python examples/offline_inference/personaplex/personaplex_offline.py \\
--input-wav input_assistant.wav --output-wav out.wav \\
--voice-prompt NATF2.pt --persona "You are a wise and friendly teacher."
"""
from __future__ import annotations
import argparse
import json
import numpy as np
from vllm_omni.experimental.fullduplex.personaplex import (
PersonaPlexConfig,
PersonaPlexEngine,
PersonaPlexSession,
)
from vllm_omni.experimental.fullduplex.personaplex.config import DEFAULT_PERSONA
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-wav", required=True, help="User-side input WAV")
parser.add_argument("--output-wav", required=True, help="Agent audio output WAV")
parser.add_argument("--output-text", default=None, help="Optional JSON for agent text")
parser.add_argument("--voice-prompt", default="NATF2.pt", help="Voice clone (bundled .pt or .wav)")
parser.add_argument("--persona", default=DEFAULT_PERSONA, help="System role text")
parser.add_argument("--hf-repo", default="nvidia/personaplex-7b-v1")
parser.add_argument("--device", default="cuda")
parser.add_argument("--cpu-offload", action="store_true")
args = parser.parse_args()
import sphn # local import: only the runnable driver needs the audio I/O dep
config = PersonaPlexConfig(
hf_repo=args.hf_repo,
voice_prompt=args.voice_prompt,
persona=args.persona,
device=args.device,
cpu_offload=args.cpu_offload,
)
engine = PersonaPlexEngine(config).load()
session = PersonaPlexSession(engine, config)
session.open()
# Load exactly like the reference (moshi.lm.load_audio): native read then
# explicit resample, so greedy runs stay bit-faithful to `python -m moshi.offline`.
user_pcm, src_sr = sphn.read(args.input_wav)
user_pcm = sphn.resample(user_pcm, src_sample_rate=src_sr, dst_sample_rate=config.sample_rate)
audio, text = session.run(np.asarray(user_pcm[0], dtype=np.float32))
sphn.write_wav(args.output_wav, audio, config.sample_rate)
print(f"wrote {audio.shape[0] / config.sample_rate:.2f}s of agent audio to {args.output_wav}")
print(f"agent text: {text!r}")
if args.output_text:
with open(args.output_text, "w") as f:
json.dump({"text": text}, f, ensure_ascii=False)
if __name__ == "__main__":
main()
@@ -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
@@ -0,0 +1,84 @@
# PersonaPlex full-duplex online serving
Serve [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
(a Moshi-based full-duplex speech-to-speech model) with the native vLLM-Omni engine.
The server hosts the **official PersonaPlex web client** at `/` (auto-downloaded from
the model repo) and implements its WebSocket protocol at `/api/chat`, so you talk to the
model live in the browser — mic in, agent speech + inner-monologue text out.
> Experimental. Requires a GPU and Hugging Face access to the gated repo
> (`HF_TOKEN` with access to `nvidia/personaplex-7b-v1`).
## Start the server
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 bash run_server.sh
# or directly:
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 python -m \
vllm_omni.experimental.fullduplex.personaplex.serving.server --port 8124
```
## Talk to it
- **Browser (recommended):** open `http://localhost:8124/` and allow the microphone.
Use headphones so the agent does not hear itself.
- **Headless:** stream a 24 kHz mono WAV and save the reply:
```bash
python duplex_client.py --url ws://localhost:8124/api/chat --input user.wav --out reply.wav
```
## Unified Realtime API
The default `vllm_omni/deploy/personaplex.yaml` also enables main's engine-owned
full-duplex control plane:
```bash
CUDA_VISIBLE_DEVICES=0 python -m vllm_omni.entrypoints.cli.main serve \
/path/to/personaplex-7b-v1 \
--omni \
--stage-configs-path vllm_omni/deploy/personaplex.yaml
```
Validate the actual `/v1/realtime?duplex=1` scheduler path with paced 24 kHz
PCM, two concurrent sessions, overflow admission, per-session slot recycling,
and non-silent output:
```bash
python tests/e2e/online_serving/personaplex_realtime_duplex.py \
--model /path/to/personaplex-7b-v1 \
--input-wav /path/to/speech.wav \
--output-dir /tmp/personaplex-realtime-duplex
```
This is distinct from the standalone `/api/chat` compatibility server above.
The unified endpoint advertises `supports_barge_in=false`: overlapping speech
is native model behavior, but destructive output interruption and model-state
rewind have not been validated for PersonaPlex.
## Protocol (`/api/chat`)
Binary WebSocket messages, first byte is a tag (identical to `moshi.server`):
| Direction | Tag | Payload |
| --- | --- | --- |
| server → client | `\x00` | ready handshake (after the system-prompt prefill) |
| client → server | `\x01` | Opus-encoded mic audio (24 kHz) |
| server → client | `\x01` | Opus-encoded agent audio |
| server → client | `\x02` | UTF-8 inner-monologue text |
Connect with query params `text_prompt` (persona) and `voice_prompt` (voice file).
A raw-PCM endpoint (`/v1/audio/duplex`, JSON `open` + float32 frames) is also available
for clients that do not want an Opus dependency.
## Notes
- **Run the client near the server.** Real-time 80 ms audio is sensitive to network
latency/jitter; over a high-latency remote link playback can stutter regardless of
engine speed. On localhost it is smooth.
- Greedy decoding by default. One conversation per server by default (a second
concurrent connection is rejected with WS close 1013 until the first ends);
pass `--batch-size N` to host N concurrent conversations on one engine
(elastic batching; connections beyond N are rejected until a slot frees).
- Full runbook: [`recipes/NVIDIA/PersonaPlex.md`](../../../recipes/NVIDIA/PersonaPlex.md).
@@ -0,0 +1,114 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Headless client for the PersonaPlex duplex server (official Moshi protocol).
Streams a 24 kHz mono WAV to ``/api/chat`` exactly as the browser client does
(Opus over WebSocket, tags ``\\x00`` ready / ``\\x01`` audio / ``\\x02`` text),
collects the agent's reply audio + inner-monologue text, and writes a WAV.
The browser UI at ``http://<host>:<port>/`` is the primary demo; this script is
for headless testing / scripting.
python duplex_client.py --url ws://localhost:8124/api/chat \
--input user.wav --out reply.wav
Deps: ``websockets``, ``sphn``, ``soundfile``, ``numpy``.
"""
from __future__ import annotations
import argparse
import asyncio
import numpy as np
import soundfile as sf
import sphn
SR = 24000 # PersonaPlex / Mimi codec rate
async def converse(url: str, pcm: np.ndarray) -> tuple[np.ndarray, str]:
import websockets
reader = sphn.OpusStreamReader(SR) # decode agent audio
writer = sphn.OpusStreamWriter(SR) # encode our mic audio
text: list[str] = []
ready = asyncio.Event()
last = {"t": 0.0}
async with websockets.connect(url, max_size=None) as ws:
async def receive() -> None:
loop = asyncio.get_event_loop()
while True:
try:
msg = await ws.recv()
except Exception:
return
if not isinstance(msg, (bytes, bytearray)) or not msg:
continue
tag, payload = msg[0], msg[1:]
if tag == 0:
ready.set()
elif tag == 1:
reader.append_bytes(bytes(payload))
last["t"] = loop.time()
elif tag == 2:
text.append(payload.decode("utf8", errors="replace"))
recv_task = asyncio.create_task(receive())
await asyncio.wait_for(ready.wait(), timeout=120) # waits for system-prompt prefill
frame = 1920 # 80 ms
for i in range(0, len(pcm), frame):
chunk = pcm[i : i + frame]
if len(chunk) < frame:
chunk = np.concatenate([chunk, np.zeros(frame - len(chunk), dtype=np.float32)])
writer.append_pcm(chunk)
data = writer.read_bytes()
if data:
await ws.send(b"\x01" + data)
await asyncio.sleep(frame / SR) # pace at real time
loop = asyncio.get_event_loop()
last["t"] = loop.time()
while loop.time() - last["t"] < 2.5: # drain until the agent stops talking
await asyncio.sleep(0.1)
recv_task.cancel()
chunks: list[np.ndarray] = []
while True:
out = reader.read_pcm()
if out is None or out.shape[-1] == 0:
break
chunks.append(np.asarray(out, dtype=np.float32))
audio = np.concatenate(chunks) if chunks else np.zeros(0, dtype=np.float32)
return audio, "".join(text)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="ws://localhost:8124/api/chat")
ap.add_argument("--input", required=True, help="24 kHz mono WAV of the user turn")
ap.add_argument("--out", default="personaplex_reply.wav")
ap.add_argument("--tail", type=float, default=10.0, help="trailing silence (s) for the agent reply")
ap.add_argument("--persona", default="")
ap.add_argument("--voice", default="NATF2.pt")
args = ap.parse_args()
wav, sr = sf.read(args.input, dtype="float32")
if wav.ndim > 1:
wav = wav.mean(axis=1)
if sr != SR:
raise SystemExit(f"input must be {SR} Hz mono; got {sr} Hz. Resample it first.")
wav = np.concatenate([wav, np.zeros(int(args.tail * SR), dtype=np.float32)])
url = f"{args.url}?text_prompt={args.persona}&voice_prompt={args.voice}"
audio, text = asyncio.run(converse(url, wav))
sf.write(args.out, audio if audio.size else np.zeros(SR, dtype=np.float32), SR)
print(f"agent reply: {audio.size / SR:.2f}s -> {args.out}")
print(f"inner-monologue: {text.strip()!r}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# PersonaPlex full-duplex online serving.
#
# Serves the OFFICIAL PersonaPlex web client at / (auto-downloaded from
# nvidia/personaplex-7b-v1) and the Moshi WebSocket protocol at /api/chat,
# backed by the native vLLM-Omni engine. Requires HF access to the gated repo.
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8124}"
echo "Starting PersonaPlex duplex server on ${HOST}:${PORT}..."
echo "Open http://localhost:${PORT}/ for the official web client (run it near the server)."
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--host "$HOST" --port "$PORT"
+6
View File
@@ -71,6 +71,12 @@ 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
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
+2 -2
View File
@@ -70,8 +70,7 @@ dev = [
"FlagEmbedding==1.4.0", # for hunyuan_image3 tests
"kernels==0.14.1", # for accuracy tests comparison with diffusers output (0.15.0 incompatible with diffusers 0.38.0)
"s3tokenizer==0.3.0", # for tts test
"step-audio2==1.0.0", # Step-Audio2 Token2Wav (flashcosyvoice / hyperpyyaml)
"stepaudio2-minicpmo", # for MiniCPM-o 4.5 talker vocoder (stepaudio2-minicpmo Token2wav)
"step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml)
]
demo = [
@@ -109,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",
+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.
+147
View File
@@ -0,0 +1,147 @@
# 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.
MiniMax H3 has two independently served checkpoint partitions:
- `FL2VA` serves text-to-video+audio (`t2va`) and
first-frame-to-video+audio (`fl2va`).
- `Ref2VA` serves image/audio or video-reference generation (`ref2va`).
## Prerequisites
### Checkpoint
Only download the partition needed by the server. Downloading the whole model
repository also retrieves duplicate shared components and the original
top-level pipeline layout.
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
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA, or to `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}/FL2VA"
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 \
--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.
- Each server process loads only one checkpoint partition.
- 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)
+5 -5
View File
@@ -1,7 +1,7 @@
# MiniMax H3 on Ascend NPU
> Joint video and audio generation with text, first-frame, image/audio, or
> multi-video conditions — Ascend NPU deployment guide
> Joint video and audio generation with text, first/last keyframes, and mixed
> image/video/audio references — Ascend NPU deployment guide
## Summary
@@ -149,9 +149,9 @@ throughput guarantee.
- 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.
- Image+audio Ref2VA accepts exactly one image and one audio reference.
- Video Ref2VA accepts one or more video files, but not an additional
standalone audio reference.
- The official H3 input matrix and media limits are documented in the [GPU
recipe](MiniMax-H3.md#official-input-matrix-and-limits); this NPU path uses
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.
+212 -18
View File
@@ -1,7 +1,7 @@
# MiniMax H3
> Joint video and audio generation with text, first-frame, image/audio, or
> multi-video conditions
> Joint video and audio generation with text, first/last keyframes, and
> mixed image/video/audio references
## Summary
@@ -16,8 +16,8 @@ checkpoint has two independently served partitions:
- `FL2VA`: text-to-video+audio (`t2va`) and first-frame-to-video+audio
(`fl2va`)
- `Ref2VA`: image+audio or one-or-more-video reference-to-video+audio
(`ref2va`)
- `Ref2VA`: up to 9 images, 3 videos, and 3 audio references in supported
image/video/audio combinations (`ref2va`; audio-only is rejected)
The generated MP4 contains H.264 video and synchronized stereo audio.
@@ -33,8 +33,10 @@ export MODEL_ROOT=/path/to/MiniMax-H3
hf download MiniMaxAI/MiniMax-H3 --local-dir "${MODEL_ROOT}"
```
Install vLLM-Omni from the checkout containing MiniMax H3 support. On
Blackwell, install the optional FlashAttention-4 dependency:
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
@@ -50,6 +52,26 @@ reference-video preparation and MP4 output.
One server loads one checkpoint partition. Set `MODEL` to `FL2VA` for T2VA
and FL2VA requests, or to `Ref2VA` for either Ref2VA request.
### 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, although the examples
serve only one partition at a time.
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
The single-GPU configuration uses model-level CPU offload.
@@ -77,6 +99,98 @@ 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.
### Two 24/32 GB GPUs: TP2 distributed layerwise offload
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="${MODEL_ROOT}/FL2VA"
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 \
--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: measured best-practice throughput
The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
@@ -85,7 +199,8 @@ The best validated four-GPU configuration on four NVIDIA B300 GPUs is:
- Ulysses sequence parallelism degree 4;
- native tiled VAE patch parallelism degree 4;
- regional `torch.compile` for the repeated DiT blocks;
- FlashAttention, with Ring and TP left at 1.
- CuTe FlashAttention-4 through the `FLASH_ATTN` backend, with Ring and TP
left at 1.
```bash
export MODEL="${MODEL_ROOT}/FL2VA"
@@ -109,6 +224,9 @@ vllm serve "${MODEL}" \
--diffusion-attention-backend FLASH_ATTN
```
On Blackwell, `FLASH_ATTN` selects FA4. Confirm the server log contains
`Using CuTe FlashAttention-4 on Blackwell` before recording measurements.
Do not add `--enforce-eager` to this performance configuration. The first
request includes regional compilation; warm the server once before measuring
steady-state latency. H3 is CFG-distilled, so `--cfg-parallel-size` must remain
@@ -225,6 +343,7 @@ curl -sS -X POST "${API_URL}" \
-F 'prompt=In a snowy blue-purple forest, Ori carefully walks past a sleeping giant; footsteps crunch in the snow while the creature breathes and softly snorts.' \
-F 'width=1344' \
-F 'height=768' \
-F 'aspect_ratio=16:9' \
-F 'fps=24' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
@@ -253,11 +372,31 @@ curl -sS -X POST "${API_URL}" \
-o fl2va.mp4
```
### 3. Ref2VA: image and audio reference
Use the same `FL2VA` partition for the official tail-keyframe forms. A single
image with `frame_indices=[-1]` conditions the last frame; two ordered images
with `frame_indices=[0,-1]` condition the first and last frames:
Run this request against the `Ref2VA` partition. `audio_reference` accepts an
HTTP(S) URL or a `data:` URL. In one terminal, expose the local reference
assets to the serving host:
```bash
export LAST_FRAME=/path/to/fl2va_last_frame.png
export FIRST_FRAME=/path/to/fl2va_first_frame.png
curl -sS -X POST "${API_URL}" \
-F 'prompt=The subject moves naturally from the first image to the last image.' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=2102' \
-F 'extra_params={"task":"fl2va","duration":8.7,"frame_indices":[0,-1],"audio_flow_shift":3.0}' \
-F "input_references=@${FIRST_FRAME};type=image/png" \
-F "input_references=@${LAST_FRAME};type=image/png" \
-o fl2va_first_last.mp4
```
### 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.
```bash
python -m http.server 8092 \
@@ -265,7 +404,24 @@ python -m http.server 8092 \
--directory /path/to/reference_assets
```
Then submit the image and audio request from another terminal:
Then submit an image-only request from another terminal:
```bash
export REF_IMAGE=/path/to/reference_assets/ref2va_image.png
curl -sS -X POST "${API_URL}" \
-F 'prompt=A white cat sits on a beige couch and slowly looks toward the camera.' \
-F 'aspect_ratio=adaptive' \
-F 'short_edge=768' \
-F 'num_inference_steps=50' \
-F 'flow_shift=12' \
-F 'seed=3100' \
-F 'extra_params={"task":"ref2va","duration":8.0,"audio_flow_shift":3.0}' \
-F "input_reference=@${REF_IMAGE};type=image/png" \
-o ref2va_image_only.mp4
```
An image-plus-audio request is:
```bash
export REF_IMAGE=/path/to/reference_assets/ref2va_image.png
@@ -288,7 +444,7 @@ curl -sS -X POST "${API_URL}" \
The requested duration should cover the complete audio. If `duration` is
shorter, the reference soundtrack is truncated to the generated clip.
### 4. Ref2VA: two video references
### 4. Ref2VA: video, separate audio, and mixed references
Run this request against the `Ref2VA` partition. Repeat the
`input_references` multipart field once per source video. H3 consumes the
@@ -313,10 +469,40 @@ curl -sS -X POST "${API_URL}" \
-o ref2va_video_video.mp4
```
The server stores uploaded multi-video references only for the lifetime of the
request and deletes the temporary files after generation. Video Ref2VA uses
the source-video soundtracks and does not accept a separate
`audio_reference`.
The server stores uploaded references only for the lifetime of the request and
deletes temporary files after generation. A video may use its embedded
soundtrack, a separate `audio_reference`, or both. To send a mixed multipart
request, repeat `input_references` for each image, video, or audio file; the
server classifies them by MIME type and preserves the per-type order.
Reference videos must be MP4/MOV with H.264/H.265 video, optional AAC/MP3
audio, 2–15 seconds each, and at most 15 seconds combined. They may still be
longer than the generated clip. Use `start_time_seconds` to select a
synchronized segment; for multiple typed video references, pass one value per
video in `extra_params.start_time_seconds`.
Reference images accept JPG/JPEG, PNG, WEBP, HEIC, or HEIF up to 30 MiB. Standalone
audio references accept WAV or MP3 up to 15 MiB, with 2–15 seconds per file and
at most 15 seconds combined.
## Official input matrix and limits
| Task | Supported references | Limits |
|------|----------------------|--------|
| T2VA | text only | prompt must be non-empty |
| FL2VA | first image, last image, or ordered first+last images | at most 2 images; `frame_indices` is `[0]`, `[-1]`, or `[0,-1]` |
| Ref2VA | image-only, image+image, image+video, video+audio, and mixed image/video/audio | images ≤9, videos ≤3, audios ≤3, total references ≤12; audio requires a visual reference |
The H3 output contract is 4–15 seconds at 24 FPS, stereo 32 kHz audio, and a
32-pixel canvas multiple. T2VA requires one named output ratio from `21:9`,
`16:9`, `4:3`, `1:1`, `3:4`, or `9:16`. FL2VA always follows the first input
image's ratio and ignores a generic `aspect_ratio` override. Ref2VA defaults to
`16:9`; `adaptive` and SGLang's `auto` spelling are accepted aliases for that
default. `short_edge` controls the 768-pixel canvas and must be `768`.
`num_outputs_per_prompt` accepts 1–10 and derives each output seed as
`seed + output_index`. The asynchronous endpoint returns all
outputs; the synchronous raw-MP4 endpoint returns the first output when more
than one is requested.
## Key parameters
@@ -329,7 +515,15 @@ the source-video soundtracks and does not accept a separate
| `flow_shift` | `12` | Video sigma shift |
| `audio_flow_shift` | `3` | Audio sigma shift, passed in `extra_params` |
| `seed` | Task-specific | Use a fixed value for reproducibility |
| `width`, `height` | Multiples of 32 | Aspect ratio must be between 1:4 and 4:1 |
| `aspect_ratio` | Task-specific | T2VA requires a named ratio; FL2VA follows the input image; Ref2VA defaults to `16:9` |
| `short_edge` | `768` | H3 shape policy requires exactly 768 when `width`/`height` are omitted |
| `num_outputs_per_prompt` | `1` | 1–10; async API returns every output |
| `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
+174
View File
@@ -0,0 +1,174 @@
# PersonaPlex
> Live full-duplex speech-to-speech (talk to the model in your browser, it listens
> while it speaks) with persona and voice control, on the native vLLM-Omni engine.
## Summary
- Vendor: NVIDIA
- Model: [`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
(gated; a Moshi finetune)
- Task: Full-duplex speech-to-speech. 24 kHz mic audio in, agent speech +
inner-monologue text out, in 80 ms lockstep. Persona (role text) and voice
(zero-shot clone) are set per session.
- Mode: Live duplex WebSocket server with the official browser client, single or
multi-session (`--batch-size`), plus an offline WAV-in / WAV-out example
- Maintainer: [`@linyueqian`](https://github.com/linyueqian)
## When to use this recipe
Use this recipe to run `nvidia/personaplex-7b-v1` as a live voice agent on one GPU.
The integration is **moshi-free**: the temporal transformer, depformer, and
streaming Mimi codec are all native vLLM-Omni modules (Mimi runs on
`transformers.MimiModel`), so no vendored fork is installed. Decoding is greedy,
matching the reference implementation frame for frame on the golden replays used
as the acceptance gate.
PersonaPlex is the first Moshi-class (pure-lockstep) model in the experimental
full-duplex framework (`vllm_omni/experimental/fullduplex/`), and the first with
elastic multi-session batching: one engine hosts `--batch-size` concurrent
conversations, callers join or leave on any 80 ms tick without disturbing live
slots (greedy isolation is bit-exact).
## References
- Serving package:
[`vllm_omni/experimental/fullduplex/personaplex/`](../../vllm_omni/experimental/fullduplex/personaplex/)
- Native model modules (temporal / depformer / streaming Mimi):
[`vllm_omni/model_executor/models/personaplex/`](../../vllm_omni/model_executor/models/personaplex/)
- Online example (server + headless client):
[`examples/online_serving/personaplex/`](../../examples/online_serving/personaplex/)
- Offline example:
[`examples/offline_inference/personaplex/personaplex_offline.py`](../../examples/offline_inference/personaplex/personaplex_offline.py)
- Staged offline pipeline (talker -> Mimi code2wav, async-chunk streaming):
[`vllm_omni/deploy/personaplex.yaml`](../../vllm_omni/deploy/personaplex.yaml)
- Integration PR: [vllm-project/vllm-omni#4771](https://github.com/vllm-project/vllm-omni/pull/4771)
- Framework context: [RFC #3745](https://github.com/vllm-project/vllm-omni/issues/3745)
(duplex adapter patterns), [#1335](https://github.com/vllm-project/vllm-omni/issues/1335)
(full-duplex target), [PR #3907](https://github.com/vllm-project/vllm-omni/pull/3907)
(fullduplex core contracts this adapter conforms to)
- Upstream model card:
[`nvidia/personaplex-7b-v1`](https://huggingface.co/nvidia/personaplex-7b-v1)
## Why it is different from MiniCPM-o / JoyVL duplex
| | MiniCPM-o 4.5 (#3907) | JoyVL | **PersonaPlex (this)** |
|---|---|---|---|
| Cadence | 1 s chunk groups | ~1 fps frames | **80 ms lockstep (12.5 Hz)** |
| Turn control | learned `⟨listen⟩`/`⟨speak⟩` | `</silence>`/`</response>` | **none, pure lockstep** |
| Per step | variable-length token group | text decision | **1 user frame in -> 1 agent frame + 1 text token** |
| Barge-in | at chunk boundary | n/a | **native (model always hears the user)** |
| Session state | chunk-group KV | per-tick HTTP | **persistent ring KV + streaming Mimi state** |
This is the lockstep ("parallel-frame joint") shape of the duplex adapter
patterns: the adapter declares `DuplexCapability.continuous = True`, and
`core.DuplexRuntime` runs ONE eternal response that consumes input frames as they
arrive and drains on close. The flag is a small, default-off lifecycle mode, so
turn-based adapters (JoyVL, MiniCPM-o) are unaffected.
## Architecture (Moshi RQ-Transformer, all native)
- **Mimi codec** 24 kHz, 12.5 Hz, 1920 samples/frame, 8 active codebooks
(card 2048). Streaming encode + decode via `transformers.MimiModel` with the
PersonaPlex reference checkpoint weights.
- **Temporal transformer** (Helium backbone) 4096-d / 32 layers / 32 heads,
sliding window 3000 frames (a 4-minute rolling context) held in a ring KV cache
with per-slot recycle.
- **Depformer** 1024-d / 6 layers / 16 heads, autoregressive over 16 codebooks
per frame (8 vocoded), reset each frame.
- Per-frame token column `[B, 17, 1]`: row 0 = inner-monologue text,
rows 1-8 = agent audio, rows 9-16 = user audio.
- Persona + voice are injected once at session open through the same lockstep
step: voice clone forces the agent stream from reference-audio Mimi codes;
persona forces the text stream from `<system> ... <system>` tokens.
## Hardware Support
Verified on GPU (Hopper-class). The serving path is plain PyTorch eager plus the
native modules, so other CUDA GPUs with enough memory are expected to work.
## GPU
### 1x 141 GB Hopper-class GPU (H20-class, verified)
#### Environment
- OS: Linux
- Python: 3.10+ (CI covers 3.11 / 3.12)
- vLLM-Omni: PR #4771 branch or current `main` once merged
- Extra dependency for the live server: `pip install sphn` (Opus framing;
`aiohttp` already ships with vLLM)
- `export HF_TOKEN=...` with access to the gated
`nvidia/personaplex-7b-v1` repo (accept the license on the model page)
First run auto-downloads from the model repo: `model.safetensors`,
`tokenizer_spm_32k_3.model`, `voices.tgz`, and the Mimi reference checkpoint;
`kyutai/mimi` is fetched via `transformers` for the codec module graph.
#### Command
Live duplex server (official browser client served at `/`):
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--port 8124 --voice NATF2.pt
```
Multi-session on the same GPU (elastic batching, callers join/leave any tick):
```bash
HF_TOKEN=... CUDA_VISIBLE_DEVICES=0 \
python -m vllm_omni.experimental.fullduplex.personaplex.serving.server \
--port 8124 --batch-size 4
```
Then open `http://localhost:8124/` and allow the microphone (use headphones so
the agent does not hear itself). Headless alternative:
```bash
python examples/online_serving/personaplex/duplex_client.py \
--url ws://localhost:8124/api/chat --input user.wav --out reply.wav
```
Offline WAV-in / WAV-out (no server):
```bash
python examples/offline_inference/personaplex/personaplex_offline.py \
--input-wav question.wav --output-wav reply.wav --output-text reply.json \
--voice-prompt NATF2.pt --persona "You are a wise and friendly teacher."
```
#### Verification
```bash
# GPU-free contract tests (stubbed FrameStepper)
pytest tests/e2e/features/fullduplex/test_personaplex_adapter.py \
tests/e2e/features/fullduplex/test_personaplex_session.py \
tests/e2e/features/fullduplex/test_personaplex_batched.py -q
# GPU e2e: batched isolation + slot recycle (coherence check needs a real
# question WAV via PPLEX_QUESTION_WAV)
pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -q
```
A green offline run producing a coherent spoken reply (and inner-monologue text
in `reply.json`) is the quickest end-to-end check.
#### Notes
- Realtime budget is 80 ms/frame. Verified eager per-tick latency at
`--batch-size 4` is ~70-74 ms on this hardware class, i.e. all four
conversations stay realtime; single-session has comfortable headroom.
- Decoding is greedy only (temperature/top-k knobs are intentionally not
exposed): greedy is what the parity gates pin against the reference
implementation, and sampling amplifies sub-bit numeric drift into divergence.
- With `--batch-size N`, connections beyond N are rejected until a slot frees;
a recycled slot is bit-exact with a fresh engine for the same inputs.
- Voice prompts: bundled `voices.tgz` names (`NATF*`/`NATM*` natural,
`VARF*`/`VARM*` varied) or a path to your own `.pt`/`.wav`.
- Run the browser client near the server. The 80 ms cadence is sensitive to
network jitter; over a high-latency link playback can stutter regardless of
engine speed. On localhost it is smooth.
- Sliding window is 3000 frames (~4 min). The ring KV recycles beyond that;
very long sessions keep running with a rolling context.
+1
View File
@@ -46,6 +46,7 @@ recipes/
| [`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, 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.
+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
+17
View File
@@ -81,6 +81,21 @@ def _from_pipeline_key(
)
def test_duplex_session_capacity_propagates_to_every_structured_stage() -> None:
omni_config = _from_pipeline_key("personaplex")
assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [2, 2]
def test_non_duplex_deploy_keeps_model_session_capacity_at_one(tmp_path: Path) -> None:
deploy_path = tmp_path / "personaplex-turn.yaml"
deploy_path.write_text("session_mode: turn\n", encoding="utf-8")
omni_config = _from_pipeline_key("personaplex", deploy_config_path=str(deploy_path))
assert [stage.model_config.duplex_max_sessions for stage in omni_config.stage_configs] == [1, 1]
@pytest.mark.parametrize("model_type", sorted(OMNI_PIPELINES))
def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(model_type: str):
pipeline = _resolve_pipeline_or_skip(model_type)
@@ -105,6 +120,7 @@ def test_vllm_omni_config_from_pipeline_config_matches_merge_pipeline_deploy(mod
assert omni_stage.runtime_config.num_replicas == legacy_stage.yaml_runtime.get("num_replicas", 1)
engine_args = legacy_stage.yaml_engine_args
assert omni_stage.model_config.duplex_max_sessions == engine_args.get("duplex_max_sessions", 1)
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")
@@ -379,6 +395,7 @@ def test_runtime_config_fields_match_rfc_runtime_scope():
def test_sub_config_fields_match_rfc_scopes():
assert {f.name for f in fields(OmniStageModelConfig)} == {
"active_stream_window",
"duplex_max_sessions",
"enable_sleep_mode",
"default_sampling_params",
"subtalker_sampling_params",
@@ -110,6 +110,9 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
session.status = RequestStatus.RUNNING
session.resumable = True
session.num_computed_tokens = len(session.prompt_token_ids)
# schedule() has counted this step's token as in flight; update_from_output()
# must settle it before handling the segment boundary.
session.num_in_flight_tokens = 1
sched = MagicMock()
sched.requests = {session.request_id: session}
@@ -118,12 +121,20 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
is_done_receiving_chunks=lambda _request_id: True,
segment_finished_requests={session.request_id},
)
sched._handle_stopped_request.return_value = False
def rearm_resumable_request(request: Request) -> bool:
request.status = RequestStatus.WAITING
return False
sched._handle_stopped_request.side_effect = rearm_resumable_request
sched.running = [session]
sched.waiting = MagicMock()
sched.skipped_waiting = MagicMock()
sched.structured_output_manager.should_advance.return_value = False
sched._pending_finish_reqs = []
# Async scheduling can observe the segment boundary in the next schedule()
# before this model output is applied and defer the same request for finish.
# update_from_output() must not re-arm the resumable request twice.
sched._pending_finish_reqs = [session]
sched.recompute_kv_load_failures = False
sched.connector = None
sched.kv_cache_manager.take_events.return_value = None
@@ -155,6 +166,8 @@ def test_resumable_generation_stop_marks_segment_boundary() -> None:
output = outputs[session.client_index].outputs[0]
assert output.finish_reason is not None
assert output.is_segment_finished is True
sched._handle_stopped_request.assert_called_once_with(session)
assert sched._pending_finish_reqs == []
def test_async_chunk_resumable_stop_rearms_connector_polling() -> None:
+316
View File
@@ -1,3 +1,6 @@
import math
from typing import NamedTuple
import pytest
import torch
@@ -317,6 +320,103 @@ def test_deferred_multimodal_cache_write_commits_on_completion():
assert torch.equal(rows[8:16], expected)
def test_discard_deferred_mm_outputs_drops_pending_chunks():
"""discard_deferred_mm_outputs is the abort path: a request that
leaves without a cache commit must not leak its staged chunks into
the cache if commit_deferred_mm_outputs is later called for it."""
cache = get_omni_pcache()
mm_key = "codes.audio"
block_table = torch.tensor(
[
[2, 3, 4, 5],
[6, 7, 8, 9],
],
dtype=torch.long,
)
input_batch = MockInputBatch(
num_computed_tokens_cpu=torch.tensor([4, 0], dtype=torch.long),
block_table=block_table,
)
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs={mm_key: torch.arange(8, dtype=torch.long).reshape(4, 2)},
num_tokens_unpadded=4,
slot_mapping=torch.arange(8, 12),
skip_mm_cache_keys={mm_key},
)
chunk = torch.arange(8, dtype=torch.long).reshape(4, 2)
cache.stage_deferred_mm_outputs(
query_start_loc=torch.tensor([0, 4], dtype=torch.long),
input_batch=input_batch,
multimodal_outputs={mm_key: chunk},
num_scheduled_tokens={"req1": 4, "req2": 0},
deferred_mm_cache_keys={mm_key},
)
assert "req1" in cache._deferred_mm_outputs
cache.discard_deferred_mm_outputs("req1")
assert "req1" not in cache._deferred_mm_outputs
# A later commit call for the now-discarded req_id must be a no-op,
# not a resurrection of the dropped chunk.
cache.commit_deferred_mm_outputs({"req1"}, input_batch)
rows = cache.mm_outputs_cache[mm_key].view(-1, 2)
assert torch.all(rows[8:12] == 0)
def test_deferred_mm_outputs_request_id_reused_after_discard():
"""A request ID reused after discard_deferred_mm_outputs must start
clean: the new occupant's committed data must not be mixed with the
previous (discarded) occupant's staged chunks."""
cache = get_omni_pcache()
mm_key = "codes.audio"
block_table = torch.tensor(
[
[2, 3, 4, 5],
[6, 7, 8, 9],
],
dtype=torch.long,
)
input_batch = MockInputBatch(
num_computed_tokens_cpu=torch.tensor([4, 0], dtype=torch.long),
block_table=block_table,
)
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs={mm_key: torch.arange(8, dtype=torch.long).reshape(4, 2)},
num_tokens_unpadded=4,
slot_mapping=torch.arange(8, 12),
skip_mm_cache_keys={mm_key},
)
stale_chunk = torch.full((4, 2), -1, dtype=torch.long)
cache.stage_deferred_mm_outputs(
query_start_loc=torch.tensor([0, 4], dtype=torch.long),
input_batch=input_batch,
multimodal_outputs={mm_key: stale_chunk},
num_scheduled_tokens={"req1": 4, "req2": 0},
deferred_mm_cache_keys={mm_key},
)
cache.discard_deferred_mm_outputs("req1")
# "req1" is reused by a brand-new request with different data.
fresh_chunk = torch.arange(8, dtype=torch.long).reshape(4, 2)
cache.stage_deferred_mm_outputs(
query_start_loc=torch.tensor([0, 4], dtype=torch.long),
input_batch=input_batch,
multimodal_outputs={mm_key: fresh_chunk},
num_scheduled_tokens={"req1": 4, "req2": 0},
deferred_mm_cache_keys={mm_key},
)
cache.commit_deferred_mm_outputs({"req1"}, input_batch)
rows = cache.mm_outputs_cache[mm_key].view(-1, 2)
assert torch.equal(rows[8:12], fresh_chunk)
def test_deferred_multimodal_cache_can_be_merged_on_full_block_hit():
cache = get_omni_pcache()
mm_key = "codes.audio"
@@ -599,3 +699,219 @@ def test_get_merged_multimodal_outputs(feat_dims, num_tokens_padded, mocker):
assert req2_merged_mm_outputs.shape == torch.Size([num_new_toks_req2, curr_feat_dim])
assert torch.all(req2_merged_mm_outputs == req2_new_mm_outputs)
@pytest.mark.parametrize(
"passthrough_shape",
[(5,), (5, 3)],
ids=["1d_token_metadata", "2d_feature_tensor"],
)
def test_get_merged_multimodal_outputs_slices_passthrough_tensor_per_request(passthrough_shape, mocker):
"""A passthrough key that is itself a real tensor (not the string/list
passthrough already covered above) must be sliced per request through
to_payload_element's tensor branch, under the same mixed cache
hit/miss + uneven scheduled-length batch as test_get_merged_multimodal_outputs.
Covers both a 1D token-aligned metadata tensor (e.g. ref_code_len,
codec_streaming -- shape (total_tokens,)) and a 2D per-token feature
tensor (shape (total_tokens, feat_dim)), since W2's batching could
easily special-case one and silently mishandle the other.
"""
feat_dims = {"foo": 100, "bar": 100}
cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN)
orig_num_tokens_unpadded = 8
slot_offset = 8
orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded)
feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
orig_mm_outputs = {
key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys
}
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs=orig_mm_outputs,
num_tokens_unpadded=orig_num_tokens_unpadded,
slot_mapping=orig_slot_mapping,
num_tokens_padded=None,
)
num_new_toks_req1 = 3
num_new_toks_req2 = 2
cache.add_prefix_cached_new_req_id("req1")
num_scheduled_tokens = {
"req1": num_new_toks_req1,
"req2": num_new_toks_req2,
}
new_mm_outputs = {}
for mm_key in cache.mm_cache_keys:
new_mm_outputs[mm_key] = torch.rand(
(num_new_toks_req1 + num_new_toks_req2, feature_dims[mm_key]),
dtype=DTYPE,
)
total_scheduled_tokens = num_new_toks_req1 + num_new_toks_req2
assert passthrough_shape[0] == total_scheduled_tokens
passthrough_tensor = torch.arange(int(torch.prod(torch.tensor(passthrough_shape))), dtype=DTYPE).reshape(
passthrough_shape
)
new_mm_outputs["passthrough_tensor"] = passthrough_tensor
input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0]))
mocker.patch(
"vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
new=fake_get_cached_block_ids,
)
merged_mm_outputs = cache.get_merged_multimodal_states(
query_start_loc=[0, num_new_toks_req1],
input_batch=input_batch,
multimodal_outputs=new_mm_outputs,
num_scheduled_tokens=num_scheduled_tokens,
)
assert "passthrough_tensor" not in cache.mm_cache_keys
passthrough_out = merged_mm_outputs["passthrough_tensor"]
# req1 (cache hit) gets tokens [0:3), req2 (cache miss) gets tokens [3:5) --
# a passthrough tensor is sliced by scheduled position, not merged with
# any cached history (unlike the mm_cache_keys tensors above).
assert torch.equal(passthrough_out["req1"], passthrough_tensor[0:num_new_toks_req1])
assert torch.equal(passthrough_out["req2"], passthrough_tensor[num_new_toks_req1:total_scheduled_tokens])
class MergeHarness(NamedTuple):
"""A primed cache plus the arguments get_merged_multimodal_states needs."""
cache: OmniTensorPrefixCache
input_batch: MockInputBatch
num_scheduled_tokens: dict[str, int]
query_start_loc: list[int]
total_scheduled_tokens: int
new_mm_outputs: dict[str, object]
def build_mixed_hit_miss_harness(mocker, num_new_toks_req1: int = 3, num_new_toks_req2: int = 2) -> MergeHarness:
"""Two requests over one cache key: req1 hits 8 cached tokens, req2 misses,
and their scheduled lengths differ -- the same batch shape as
test_get_merged_multimodal_outputs, which is what makes a per-request
boundary error observable. Callers add their passthrough keys to
``new_mm_outputs`` before calling get_merged_multimodal_states.
"""
cache = get_omni_pcache_with_mm_tensors({"foo": 100}, seq_len=DEFAULT_SEQ_LEN)
orig_num_tokens_unpadded = 8
feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs={
key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys
},
num_tokens_unpadded=orig_num_tokens_unpadded,
slot_mapping=torch.arange(8, 8 + orig_num_tokens_unpadded),
num_tokens_padded=None,
)
cache.add_prefix_cached_new_req_id("req1")
total_scheduled_tokens = num_new_toks_req1 + num_new_toks_req2
mocker.patch(
"vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
new=fake_get_cached_block_ids,
)
return MergeHarness(
cache=cache,
input_batch=MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0])),
num_scheduled_tokens={"req1": num_new_toks_req1, "req2": num_new_toks_req2},
query_start_loc=[0, num_new_toks_req1],
total_scheduled_tokens=total_scheduled_tokens,
new_mm_outputs={
key: torch.rand((total_scheduled_tokens, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys
},
)
def test_get_merged_multimodal_outputs_passes_per_request_tensor_list_through(mocker):
"""The passthrough shape qwen3-tts actually emits: a nested ``codes``/``meta``
dict whose ``codes.ref`` holds one tensor per request
(qwen3_tts_talker.make_omni_output), carried through to_payload_element's
pass_lists_through branch beside 1D int metadata that IS sliced per request.
Each request must receive the list *whole*. Per-request indexing happens
later, in the runner's _unwrap_lists; a W2 that split the list here would
make that second index select a different request's reference codes. The
existing list coverage passes a list of strings, which cannot catch a
tensor-specific regression, and no other test covers an int dtype.
"""
harness = build_mixed_hit_miss_harness(mocker)
ref_codes = [torch.tensor([11, 12], dtype=torch.long), torch.tensor([21], dtype=torch.long)]
ref_code_len = torch.arange(harness.total_scheduled_tokens, dtype=torch.int32)
harness.new_mm_outputs["codes"] = {"ref": ref_codes}
harness.new_mm_outputs["meta"] = {"ref_code_len": ref_code_len}
merged = harness.cache.get_merged_multimodal_states(
query_start_loc=harness.query_start_loc,
input_batch=harness.input_batch,
multimodal_outputs=harness.new_mm_outputs,
num_scheduled_tokens=harness.num_scheduled_tokens,
)
assert "codes" not in harness.cache.mm_cache_keys
for req_id in ("req1", "req2"):
req_refs = merged["codes"][req_id]["ref"]
assert len(req_refs) == len(ref_codes)
for got, want in zip(req_refs, ref_codes):
assert torch.equal(got, want)
assert got.dtype == torch.long
# Per-request clones: the branch exists so one request's downstream mutation
# cannot reach another request's payload or the source list.
merged["codes"]["req1"]["ref"][0] += 100
assert torch.equal(merged["codes"]["req2"]["ref"][0], torch.tensor([11, 12], dtype=torch.long))
assert torch.equal(ref_codes[0], torch.tensor([11, 12], dtype=torch.long))
# Token-aligned 1D metadata is still split per request, unlike the list.
req1_len = harness.num_scheduled_tokens["req1"]
assert torch.equal(merged["meta"]["req1"]["ref_code_len"], ref_code_len[:req1_len])
assert torch.equal(merged["meta"]["req2"]["ref_code_len"], ref_code_len[req1_len:])
assert merged["meta"]["req1"]["ref_code_len"].dtype == torch.int32
@pytest.mark.parametrize(
"passthrough_shape",
[(3, 4), (1, 4), (7,)],
ids=["shorter_2d", "single_row_2d", "longer_1d"],
)
def test_get_merged_multimodal_outputs_broadcasts_non_token_aligned_passthrough(passthrough_shape, mocker):
"""A passthrough tensor whose first dimension is not the scheduled-token
count is request-invariant: to_payload_element hands every request the whole
tensor, cloned, rather than slicing it.
W2 batching most plausibly breaks by assuming every passthrough tensor is
token-aligned; that assumption would slice these and give each request a
truncated view, or an empty one once start exceeds the first dimension.
"""
harness = build_mixed_hit_miss_harness(mocker)
assert passthrough_shape[0] != harness.total_scheduled_tokens
passthrough = torch.arange(math.prod(passthrough_shape), dtype=DTYPE).reshape(passthrough_shape)
expected = passthrough.clone()
harness.new_mm_outputs["speaker_embedding"] = passthrough
merged = harness.cache.get_merged_multimodal_states(
query_start_loc=harness.query_start_loc,
input_batch=harness.input_batch,
multimodal_outputs=harness.new_mm_outputs,
num_scheduled_tokens=harness.num_scheduled_tokens,
)
assert "speaker_embedding" not in harness.cache.mm_cache_keys
out = merged["speaker_embedding"]
for req_id in ("req1", "req2"):
assert out[req_id].shape == passthrough_shape
assert torch.equal(out[req_id], expected)
out["req1"] += 100
assert torch.equal(out["req2"], expected)
assert torch.equal(passthrough, expected)
+119
View File
@@ -0,0 +1,119 @@
import pytest
import torch
from vllm_omni.core.prefix_cache import OmniTensorPrefixCache
# `cpu` is the lane selector, not a hardware claim: the only Buildkite steps that
# reach tests/ select `core_model and cpu`, and they run on GPU hardware (l4_1).
# Marking this `cuda` collects it nowhere. Real hardware need is the skipif below.
pytestmark = [
pytest.mark.core_model,
pytest.mark.cpu,
pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA device"),
]
NUM_BLOCKS = 10
BLOCK_SIZE = 4
HIDDEN_SIZE = 2
DTYPE = torch.float32
def get_omni_pcache() -> OmniTensorPrefixCache:
"""Build an OmniTensorPrefixCache, but don't init mm tensors."""
return OmniTensorPrefixCache(
num_blocks=NUM_BLOCKS,
block_size=BLOCK_SIZE,
hidden_size=HIDDEN_SIZE,
hs_dtype=DTYPE,
)
def test_drain_ready_async_writes_is_a_noop_before_the_event_is_ready(monkeypatch):
"""drain_ready_async_writes() must not touch the cache while the
scheduled D2H's event has not completed, and must consume it exactly
once after it has.
Real GPU timing (e.g. via torch.cuda._sleep to stall the copy stream)
was tried and found unreliable on this box: cross-stream wait_stream/
wait_event did not reliably delay event.query() from reporting ready
immediately, likely a virtualization/driver quirk of this cloud GPU.
Forcing the event's query() result directly tests the branching logic
in drain_ready_async_writes/_consume_pending_write deterministically,
independent of real CUDA scheduling behavior.
"""
cache = get_omni_pcache()
mm_key = "codes.audio"
cache.maybe_init_missing_mm_cache_keys({mm_key: torch.zeros(4, HIDDEN_SIZE)}, seq_len=4)
num_tokens = 4
slot_mapping_gpu = torch.arange(8, 8 + num_tokens, dtype=torch.int64, device="cuda")
mm_gpu = {
mm_key: torch.arange(num_tokens * HIDDEN_SIZE, dtype=torch.float32, device="cuda").reshape(
num_tokens, HIDDEN_SIZE
)
}
cache.schedule_async_write(
hidden_states_gpu=None,
multimodal_outputs_gpu=mm_gpu,
slot_mapping_gpu=slot_mapping_gpu,
num_tokens_unpadded=num_tokens,
num_tokens_padded=num_tokens,
)
pending_event = cache._pending_write.event
rows = cache.mm_outputs_cache[mm_key].view(-1, HIDDEN_SIZE)
monkeypatch.setattr(pending_event, "query", lambda: False)
assert cache.drain_ready_async_writes() == 0
assert torch.all(rows[8 : 8 + num_tokens] == 0)
assert cache._pending_write is not None
monkeypatch.setattr(pending_event, "query", lambda: True)
assert cache.drain_ready_async_writes() == 1
assert torch.equal(rows[8 : 8 + num_tokens], mm_gpu[mm_key].cpu())
assert cache._pending_write is None
# Nothing pending anymore: a second drain is a no-op, not a re-scatter.
assert cache.drain_ready_async_writes() == 0
def test_schedule_async_write_consumes_previous_pending_write_first():
"""Scheduling step N+1's write must first consume (scatter) step N's
pending write, even if the caller never explicitly called
drain_ready_async_writes() in between -- otherwise step N's data would
be silently dropped when overwritten by step N+1's pending write."""
cache = get_omni_pcache()
mm_key = "codes.audio"
cache.maybe_init_missing_mm_cache_keys({mm_key: torch.zeros(4, HIDDEN_SIZE)}, seq_len=4)
num_tokens = 4
first_slots = torch.arange(8, 8 + num_tokens, dtype=torch.int64, device="cuda")
first_gpu = {mm_key: torch.full((num_tokens, HIDDEN_SIZE), 1.0, device="cuda")}
second_slots = torch.arange(12, 12 + num_tokens, dtype=torch.int64, device="cuda")
second_gpu = {mm_key: torch.full((num_tokens, HIDDEN_SIZE), 2.0, device="cuda")}
cache.schedule_async_write(
hidden_states_gpu=None,
multimodal_outputs_gpu=first_gpu,
slot_mapping_gpu=first_slots,
num_tokens_unpadded=num_tokens,
num_tokens_padded=num_tokens,
)
torch.accelerator.synchronize()
# This call's internal _consume_pending_write() must flush the FIRST
# write before the second one is scheduled; the caller never drained.
cache.schedule_async_write(
hidden_states_gpu=None,
multimodal_outputs_gpu=second_gpu,
slot_mapping_gpu=second_slots,
num_tokens_unpadded=num_tokens,
num_tokens_padded=num_tokens,
)
torch.accelerator.synchronize()
cache.drain_ready_async_writes()
rows = cache.mm_outputs_cache[mm_key].view(-1, HIDDEN_SIZE)
assert torch.all(rows[8:12] == 1.0)
assert torch.all(rows[12:16] == 2.0)
+8 -146
View File
@@ -1,16 +1,15 @@
import copy
import json
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.runtime import OmniServerParams
from tests.helpers.stage_config import modify_stage_config
@@ -119,32 +118,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 +330,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 +358,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:
@@ -637,57 +564,6 @@ def extract_configs_resource_label(configs: list[dict[str, Any]]) -> str:
return get_runtime_resource_label()
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("list baseline requires sweep_index")
if not (0 <= sweep_index < len(baseline_raw)):
raise IndexError(f"baseline list len={len(baseline_raw)} has no index {sweep_index}")
return baseline_raw[sweep_index]
return baseline_raw
def _baseline_thresholds_for_step(
baseline_data: dict[str, Any],
*,
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,
)
for metric_name, baseline_raw in baseline_data.items()
}
def run_benchmark(
args: list[str],
test_name: str,
@@ -696,18 +572,17 @@ def run_benchmark(
num_prompt: int,
*,
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 raw
``baseline`` object from the test config (when ``baseline_config`` is provided),
including any hardware-nested map (e.g. ``{"H100": {...}}``). Downstream report
tooling can select a hardware bucket when needed. 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,12 +649,8 @@ def run_benchmark(
result = json.load(f)
if baseline_config:
result["baseline"] = _baseline_thresholds_for_step(
baseline_config,
sweep_index=sweep_index,
request_rate=request_rate,
max_concurrency=max_concurrency,
)
# Persist the JSON baseline as-is (no hardware / sweep resolution).
result["baseline"] = copy.deepcopy(baseline_config)
else:
result["baseline"] = {}
if random_input_len is not None:
@@ -800,12 +671,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)."
),
)
+6 -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 qps, num_prompt in 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,14 @@ 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,
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 concurrency, num_prompt in 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 +262,8 @@ 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,
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)
+12 -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"]``).
@@ -25,6 +23,7 @@ of all runs from cases in that file). Bulk load without ``--test-config-file`` u
the same per-file aggregation; ``-m`` only selects which cases run.
"""
import copy
import json
import os
import socket
@@ -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()
# ---------------------------------------------------------------------------
@@ -678,7 +672,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 +766,17 @@ def run_benchmark(
completed = metrics.get("completed_requests", metrics.get("completed", 0))
failed = metrics.get("failed_requests", metrics.get("failed", 0))
# Persist the JSON baseline as-is (no hardware / sweep resolution).
baseline = copy.deepcopy(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 +826,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."""
@@ -919,7 +862,6 @@ def _build_run_params(
params: dict[str, Any],
*,
num_prompts: int,
sweep_index: int | None = None,
request_rate: Any | None = None,
max_concurrency: Any | None = None,
) -> dict[str, Any]:
@@ -931,16 +873,6 @@ def _build_run_params(
run_params["request-rate"] = request_rate
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()
}
return run_params
@@ -963,36 +895,28 @@ 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 request_rate, num_prompts in 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,
),
"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 max_concurrency, num_prompts in 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",
),
"num_prompts": num_prompts,
"sweep_index": i,
"request_rate": None,
"max_concurrency": max_concurrency,
}
)
@@ -1005,9 +929,6 @@ def _iter_sweep_runs(params: dict[str, Any]) -> list[dict[str, Any]]:
num_prompts=default_num_prompts,
),
"num_prompts": default_num_prompts,
"sweep_index": None,
"request_rate": None,
"max_concurrency": None,
}
)
@@ -1023,14 +944,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 +992,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": {"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
}
}
}
]
@@ -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": {"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
}
}
}
]
@@ -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
"H100": {
"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
"H100": {
"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
"H100": {
"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
"H100": {
"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
}
]
}
@@ -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]
}
}
}
]
+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
}
}
}
]
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Omni sleep/wakeup JSON validation (live ``Qwen/Qwen-Image`` process)."""
"""Omni sleep/wakeup JSON validation and level-2 wake contract (live ``Qwen/Qwen-Image``)."""
from __future__ import annotations
@@ -15,9 +15,17 @@ pytestmark = [pytest.mark.slow, pytest.mark.diffusion, pytest.mark.omni]
_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649")
# One module-scoped server for this file. ``--enable-sleep-mode`` is required by the
# level-2 wake contract test; invalid-JSON cases only hit request validation and are
# unaffected. Do not mix ``omni_server`` (module) with ``omni_server_function`` here:
# ``iter_omni_server`` holds ``omni_fixture_lock`` across the module yield, so a
# function-scoped second server would deadlock / contend for the GPU.
_QWEN_IMAGE = [
pytest.param(
OmniServerParams(model="Qwen/Qwen-Image"),
OmniServerParams(
model="Qwen/Qwen-Image",
server_args=["--enable-sleep-mode"],
),
id="qwen_image",
marks=hardware_marks(res={"cuda": "H100"}),
),
@@ -93,3 +101,37 @@ def test_omni_sleep_wakeup_invalid_requests(
openai_client.send_omni_sleep_http_request(cfg)
else:
openai_client.send_omni_wakeup_http_request(cfg)
# Keep this after the invalid-JSON cases: sleep(level=2) discards weights and the
# shared module server is not usable afterward; module teardown then stops it.
@pytest.mark.parametrize("omni_server", _QWEN_IMAGE, indirect=True)
def test_wakeup_after_level2_sleep_fails(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
) -> None:
"""Regression for #4473 Repro A via HTTP: wake after sleep(level=2) → 501.
Moved from ``tests/entrypoints/test_omni_sleep_mode.py`` (BAGEL cold start).
Live serve surfaces ``NotImplementedError`` as OpenAI-style JSON with code 501.
"""
assert omni_server is not None
sleep_resps = openai_client.send_omni_sleep_http_request(
{
"json": {"stage_ids": [0], "level": 2},
"timeout": 180,
}
)
sleep_resp = sleep_resps[0]
assert sleep_resp.status_code == 200, sleep_resp
assert sleep_resp.json_body is not None
assert sleep_resp.json_body.get("status") == "SUCCESS", sleep_resp.json_body
openai_client.send_omni_wakeup_http_request(
{
"json": {"stage_ids": [0]},
"timeout": 120,
"err_code": 501,
"err_message": ("sleep(level=2)", "not yet implemented"),
}
)
@@ -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}),
)
@@ -30,6 +30,13 @@ def test_pipeline_import_registry_and_component_discovery():
assert MiniMaxH3Pipeline._vae_modules == ["video_vae", "audio_vae"]
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 +94,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
@@ -102,6 +190,52 @@ def test_reference_image_resize_contract():
_reference_image_shape(Image.new("RGB", (100, 501)))
def test_fl2va_supports_first_last_and_explicit_frame_index_contracts():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_resolve_fl2va_keyframe_indices,
)
assert _resolve_fl2va_keyframe_indices({}, 1) == [0]
assert _resolve_fl2va_keyframe_indices({}, 2) == [0, -1]
assert _resolve_fl2va_keyframe_indices({"frame_index": -1}, 1) == [-1]
assert _resolve_fl2va_keyframe_indices({"frame_indices": [0, -1]}, 2) == [0, -1]
with pytest.raises(ValueError, match="frame_indices"):
_resolve_fl2va_keyframe_indices({"frame_indices": [0, 1]}, 2)
def test_minimax_h3_uses_the_official_output_canvas_policy():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_resolve_output_canvas,
)
assert _resolve_output_canvas(21 / 9, 768) == (672, 1536)
assert _resolve_output_canvas(16 / 9, 768) == (768, 1344)
assert _resolve_output_canvas(9 / 16, 768) == (1344, 768)
with pytest.raises(ValueError, match="short_edge"):
_resolve_output_canvas(16 / 9, 720)
def test_minimax_h3_accepts_sglang_auto_aspect_ratio_alias():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
sampling = SimpleNamespace(
fps=24,
num_frames=1,
height=None,
width=None,
extra_args={"duration": 5.0, "aspect_ratio": "auto"},
)
height, width, *_ = pipeline._resolve_shape("ref2va", sampling, None)
assert (height, width) == (768, 1344)
def test_minimax_h3_advertises_the_official_ref2va_image_limit():
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
assert get_diffusion_model_metadata("MiniMaxH3Pipeline").max_multimodal_image_inputs == 9
def test_encoder_forward_uses_hook_compatible_encode_entrypoint():
from vllm_omni.diffusion.models.minimax_h3.encoder import (
MiniMaxH3Qwen3VLEncoder,
@@ -247,6 +381,127 @@ 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_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
@@ -278,6 +533,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,
@@ -373,3 +704,360 @@ def test_distributed_video_vae_encodes_references_sequentially(monkeypatch):
torch.tensor([[1.0, 1.0], [2.0, 2.0]]),
)
assert shapes == [(1, 2, 3), (2, 2, 3)]
@pytest.mark.parametrize(
("case", "extra", "image_count", "expected"),
[
("F1", {"frame_index": -1}, 1, [-1]),
("F2", {"frame_indices": [0, -1]}, 2, [0, -1]),
],
)
def test_f1_f2_official_fl2va_keyframe_matrix(case, extra, image_count, expected):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_resolve_fl2va_keyframe_indices,
)
assert case in {"F1", "F2"}
assert _resolve_fl2va_keyframe_indices(extra, image_count) == expected
@pytest.mark.parametrize(
("case", "counts"),
[
("R1", (3, 0, 0)),
("R2", (1, 0, 0)),
("R3", (1, 1, 0)),
("R4", (0, 1, 1)),
("R5", (1, 1, 1)),
("R6", (1, 0, 2)),
],
)
def test_r1_r6_ref2va_reference_count_matrix(case, counts):
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_validate_ref2va_reference_counts,
)
assert case.startswith("R")
_validate_ref2va_reference_counts(*counts)
def test_ref2va_reference_count_validation_preserves_client_error_metadata():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_validate_ref2va_reference_counts,
)
from vllm_omni.errors import OmniClientError
with pytest.raises(OmniClientError, match="at least one image or video"):
_validate_ref2va_reference_counts(0, 0, 0)
@pytest.mark.parametrize(
("case", "start_time", "expected_duration"),
[
("R7", None, 10.0),
("R8", 4.0, 6.0),
],
)
def test_r7_r8_ref2va_video_segment_matrix(monkeypatch, tmp_path, case, start_time, expected_duration):
from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module
source = tmp_path / f"{case}.mp4"
source.touch()
metadata = {
"width": 1280,
"height": 720,
"fps": 24.0,
"frame_count": 240,
"duration": 10.0,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": (),
"file_size": 1024,
}
transcode_calls = []
monkeypatch.setattr(reference_video_module, "_probe_video", lambda _path: metadata)
monkeypatch.setattr(
reference_video_module,
"_transcode_reference_video",
lambda source, **kwargs: transcode_calls.append((source, kwargs)) or "prepared.mp4",
)
prepared = reference_video_module.prepare_reference_videos(
[str(source)],
target_frame_count=209,
workdir=str(tmp_path / "work"),
start_time_seconds=start_time,
)
assert prepared[0]["duration_seconds"] == pytest.approx(expected_duration)
assert prepared[0]["start_time_seconds"] == pytest.approx(start_time or 0.0)
assert transcode_calls[0][1]["duration_seconds"] == pytest.approx(expected_duration)
def test_ref2va_two_video_recipe_tolerates_container_rounding(monkeypatch, tmp_path):
from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module
first = tmp_path / "first.mp4"
second = tmp_path / "second.mp4"
first.touch()
second.touch()
metadata = {
str(first): {
"width": 1280,
"height": 720,
"fps": 24.0,
"frame_count": 241,
"duration": 10.041667,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": (),
"file_size": 1024,
},
str(second): {
"width": 1280,
"height": 720,
"fps": 24.0,
"frame_count": 120,
"duration": 4.966667,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": (),
"file_size": 1024,
},
}
transcode_calls = []
monkeypatch.setattr(reference_video_module, "_probe_video", lambda path: metadata[str(path)])
monkeypatch.setattr(
reference_video_module,
"_transcode_reference_video",
lambda source, **kwargs: transcode_calls.append((source, kwargs)) or f"{source}.prepared.mp4",
)
prepared = reference_video_module.prepare_reference_videos(
[str(first), str(second)],
target_frame_count=124,
workdir=str(tmp_path / "work"),
)
assert [item["duration_seconds"] for item in prepared] == pytest.approx([10.041667, 4.958333])
assert sum(item["duration_seconds"] for item in prepared) == pytest.approx(15.0)
assert transcode_calls[1][1]["duration_seconds"] == pytest.approx(4.958333)
def test_ref2va_two_video_recipe_rejects_real_duration_overflow(monkeypatch, tmp_path):
from vllm_omni.diffusion.models.minimax_h3 import reference_video as reference_video_module
from vllm_omni.errors import OmniClientError
first = tmp_path / "first.mp4"
second = tmp_path / "second.mp4"
first.touch()
second.touch()
metadata = {
str(first): {
"width": 1280,
"height": 720,
"fps": 24.0,
"frame_count": 240,
"duration": 10.0,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": (),
"file_size": 1024,
},
str(second): {
"width": 1280,
"height": 720,
"fps": 24.0,
"frame_count": 120,
"duration": 5.02,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": (),
"file_size": 1024,
},
}
monkeypatch.setattr(reference_video_module, "_probe_video", lambda path: metadata[str(path)])
monkeypatch.setattr(reference_video_module, "_transcode_reference_video", lambda source, **kwargs: "prepared.mp4")
with pytest.raises(OmniClientError, match="15 seconds"):
reference_video_module.prepare_reference_videos(
[str(first), str(second)],
target_frame_count=124,
workdir=str(tmp_path / "work"),
)
@pytest.mark.parametrize("duration", [4.0, 15.0])
def test_g2_output_duration_accepts_official_boundaries(duration):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
sampling = SimpleNamespace(
fps=24,
num_frames=1,
height=None,
width=None,
extra_args={"duration": duration},
)
height, width, *_ = pipeline._resolve_shape("ref2va", sampling, None)
assert height > 0 and width > 0
@pytest.mark.parametrize("duration", [3.99, 15.01, float("nan"), "not-a-duration"])
def test_g2_output_duration_rejects_out_of_contract_values(duration):
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
sampling = SimpleNamespace(
fps=24,
num_frames=1,
height=None,
width=None,
extra_args={"duration": duration},
)
with pytest.raises(ValueError, match="duration"):
pipeline._resolve_shape("ref2va", sampling, None)
def test_g1_fanout_uses_incrementing_output_seeds():
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_minimax_h3_output_seeds,
_resolve_minimax_h3_num_outputs,
)
assert _resolve_minimax_h3_num_outputs(3) == 3
assert _minimax_h3_output_seeds(100, 3) == [100, 101, 102]
with pytest.raises(ValueError, match="num_outputs_per_prompt"):
_resolve_minimax_h3_num_outputs(11)
def test_g3_task_specific_aspect_ratio_policy():
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_resolve_minimax_h3_aspect_ratio,
)
image = Image.new("RGB", (1280, 720))
assert _resolve_minimax_h3_aspect_ratio("fl2va", "9:16", image) == pytest.approx(16 / 9)
assert _resolve_minimax_h3_aspect_ratio("ref2va", None, None) == pytest.approx(16 / 9)
assert _resolve_minimax_h3_aspect_ratio("ref2va", "auto", None) == pytest.approx(16 / 9)
with pytest.raises(ValueError, match="requires an explicit"):
_resolve_minimax_h3_aspect_ratio("t2va", None, None)
with pytest.raises(ValueError, match="one of"):
_resolve_minimax_h3_aspect_ratio("t2va", "2:1", None)
def test_g3_t2va_shape_requires_a_named_ratio_and_fl2va_ignores_override():
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
pipeline = object.__new__(MiniMaxH3Pipeline)
t2va_sampling = SimpleNamespace(
fps=24,
num_frames=1,
height=None,
width=None,
extra_args={"duration": 5.0},
)
with pytest.raises(ValueError, match="requires an explicit aspect_ratio"):
pipeline._resolve_shape("t2va", t2va_sampling, None)
fl2va_sampling = SimpleNamespace(
fps=24,
num_frames=1,
height=None,
width=None,
extra_args={"duration": 5.0, "aspect_ratio": "9:16"},
)
height, width, *_ = pipeline._resolve_shape("fl2va", fl2va_sampling, Image.new("RGB", (1280, 720)))
assert (height, width) == (768, 1344)
def test_g4_reference_image_boundaries_and_aspect_ratio():
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_validate_reference_image,
)
_validate_reference_image(Image.new("RGB", (256, 256)))
_validate_reference_image(Image.new("RGB", (5760, 2304)))
with pytest.raises(ValueError, match="dimensions"):
_validate_reference_image(Image.new("RGB", (255, 255)))
with pytest.raises(ValueError, match="aspect ratio"):
_validate_reference_image(Image.new("RGB", (256, 641)))
def test_g4_reference_image_file_format_and_size_contract(tmp_path):
from PIL import Image
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
_load_image,
)
valid_path = tmp_path / "reference.png"
Image.new("RGB", (256, 256)).save(valid_path)
assert _load_image(valid_path).size == (256, 256)
invalid_path = tmp_path / "reference.bmp"
Image.new("RGB", (256, 256)).save(invalid_path)
with pytest.raises(ValueError, match="must use"):
_load_image(invalid_path)
def test_g4_standalone_audio_duration_and_total_duration_contract():
from vllm_omni.diffusion.models.minimax_h3.reference_video import (
validate_reference_audio_waveforms,
)
sample_rate = 16000
validate_reference_audio_waveforms(
[
(torch.zeros(1, 2 * sample_rate), sample_rate),
(torch.zeros(1, 13 * sample_rate), sample_rate),
]
)
with pytest.raises(ValueError, match="duration"):
validate_reference_audio_waveforms([(torch.zeros(1, int(1.9 * sample_rate)), sample_rate)])
with pytest.raises(ValueError, match="at most 15 seconds in total"):
validate_reference_audio_waveforms(
[
(torch.zeros(1, 8 * sample_rate), sample_rate),
(torch.zeros(1, 8 * sample_rate), sample_rate),
]
)
@pytest.mark.parametrize(
("field", "value", "message"),
[
("fps", 23.0, "FPS"),
("duration", 1.0, "duration"),
("format_names", ("avi",), "container"),
("video_codec", "vp9", "H.264"),
("audio_codecs", ("opus",), "AAC"),
("file_size", 50 * 1024 * 1024 + 1, "size"),
],
)
def test_g4_reference_video_metadata_validation(field, value, message, tmp_path):
from vllm_omni.diffusion.models.minimax_h3.reference_video import (
_validate_reference_video_metadata,
)
metadata = {
"width": 1280,
"height": 720,
"fps": 24.0,
"duration": 10.0,
"format_names": ("mp4",),
"video_codec": "h264",
"audio_codecs": ("aac",),
"file_size": 1024,
}
metadata[field] = value
with pytest.raises(ValueError, match=message):
_validate_reference_video_metadata(metadata, index=0, source=str(tmp_path / "reference.mp4"))
@@ -61,6 +61,8 @@ def test_minimax_h3_t2va_ulysses8_smoke():
output_type="np",
extra_args={
"task": "t2va",
"duration": 4.0,
"aspect_ratio": "16:9",
"flow_shift": 12.0,
"audio_flow_shift": 3.0,
},
@@ -70,7 +72,14 @@ def test_minimax_h3_t2va_ulysses8_smoke():
finally:
engine.close()
_assert_joint_output(outputs)
assert len(outputs) == 1
frames = np.asarray(outputs[0].images[0])
assert frames.shape == (107, 256, 448, 3)
multimodal = outputs[0].multimodal_output
assert multimodal is not None
assert np.asarray(multimodal["audio"]).shape[1] == 2
assert multimodal["audio_sample_rate"] == 32000
assert multimodal["fps"] == 24
@pytest.mark.skipif(
@@ -144,3 +144,20 @@ def test_condition_noise_is_seeded_and_keeps_clean_anchor_at_timestep_one():
noise_aug=0.25,
)
torch.testing.assert_close(first, second)
def test_condition_noise_accepts_a_reference_video_longer_than_the_target():
from vllm_omni.diffusion.models.minimax_h3.condition_noise import (
minimax_h3_imgvid_cond_noise_aug_rows,
)
rows = torch.zeros(4 * 2 * 2, 96)
result = minimax_h3_imgvid_cond_noise_aug_rows(
rows,
condition_shapes=[(4, 4, 4)],
target_latent_t=2,
imgvid_cond_num_frames=1,
seed=7,
noise_aug=0.5,
)
assert result.shape == rows.shape
@@ -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."""
@@ -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 ─────────────────
@@ -390,6 +390,105 @@ def test_send_single_request_respects_processor_receiver_boundary(build_adapter,
assert sent_payload.meta.is_segment_finished.item() is False
def test_send_single_request_personaplex_pending_frame_is_not_segment_boundary(
build_adapter,
):
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
)
adapter, connector = build_adapter(
stage_id=0,
connector_extra={
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
},
)
request = _req(
"req-personaplex",
RequestStatus.WAITING,
external_req_id="ext-personaplex",
)
request.resumable = True
request.additional_information = {
"codes": {
"audio": torch.arange(8, dtype=torch.long).reshape(1, 8),
}
}
adapter.custom_process_next_stage_input_func = talker2code2wav_async_chunk
adapter._send_single_request(
{
"multimodal_output": None,
"request": request,
"is_finished": False,
"is_segment_finished": True,
}
)
sent_payload = connector.put.call_args.kwargs["data"]
assert sent_payload.codes is None
assert sent_payload.meta.finished.item() is False
assert sent_payload.meta.is_segment_finished.item() is False
def test_personaplex_sender_cleanup_drops_delayed_frame_state(build_adapter):
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
)
adapter, _ = build_adapter(
stage_id=0,
connector_extra={
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
},
)
request = _req(
"req-personaplex-first",
RequestStatus.WAITING,
external_req_id="ext-personaplex-reused",
)
request.resumable = True
request.additional_information = {
"codes": {
"audio": torch.arange(8, dtype=torch.long).reshape(1, 8),
}
}
first = talker2code2wav_async_chunk(
adapter,
multimodal_output=None,
request=request,
is_finished=True,
)
assert first is not None
assert first.codes is None
adapter.cleanup_sender(request.external_req_id)
replacement = _req(
"req-personaplex-replacement",
RequestStatus.WAITING,
external_req_id=request.external_req_id,
)
replacement.resumable = True
replacement.additional_information = {
"codes": {
"audio": torch.arange(8, 16, dtype=torch.long).reshape(1, 8),
}
}
second = talker2code2wav_async_chunk(
adapter,
multimodal_output=None,
request=replacement,
is_finished=True,
)
assert second is not None
assert second.codes is None
def test_save_async_skips_stale_resumable_chunk_until_dedup_is_reset(build_adapter):
adapter, _ = build_adapter(stage_id=1)
request = _req("req-stream", RequestStatus.WAITING, external_req_id="ext-stream")
+1
View File
@@ -0,0 +1 @@
"""End-to-end accuracy tests."""
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import json
import os
import subprocess
@@ -26,9 +27,13 @@ from tests.helpers.runtime import OmniServer
pytestmark = [pytest.mark.benchmark, pytest.mark.diffusion, pytest.mark.full_model]
MODEL_REPO_ID = "MiniMaxAI/MiniMax-H3"
MODEL_REVISION = "73372e6cf53e414edd3ab03e357717fb0602e758"
# The previously pinned revision was removed from the Hugging Face repository.
MODEL_REVISION = "main"
MODEL_ENV_VAR = "VLLM_TEST_MINIMAX_H3_FL2VA_MODEL"
REFERENCE_VIDEO_URL = f"https://huggingface.co/{MODEL_REPO_ID}/resolve/main/assets/t2va.mp4"
# Keep the official asset link visible in the test report. Hugging Face's
# ``blob`` URL is an HTML page, so resolve it to the raw asset for download.
REFERENCE_VIDEO_URL = "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/assets/t2va.mp4"
REFERENCE_VIDEO_SHA256 = "d66903241362e224085cc93f7a5e70fba6ab378d0ac6ff6af83acf2559849a42"
# This is the official reproducible 768p T2VA prompt associated with
# ``assets/t2va.mp4`` in the MiniMax H3 repository.
@@ -93,9 +98,15 @@ def _server_args() -> list[str]:
def _download_reference_video(output_dir: Path) -> Path:
reference_path = output_dir / "reference.mp4"
response = requests.get(REFERENCE_VIDEO_URL, timeout=120)
download_url = REFERENCE_VIDEO_URL.replace("/blob/", "/resolve/", 1)
response = requests.get(download_url, timeout=120)
response.raise_for_status()
reference_path.write_bytes(response.content)
content = response.content
digest = hashlib.sha256(content).hexdigest()
assert digest == REFERENCE_VIDEO_SHA256, (
f"Official MiniMax H3 T2VA reference changed: got {digest}, expected {REFERENCE_VIDEO_SHA256}"
)
reference_path.write_bytes(content)
return reference_path
@@ -159,6 +170,7 @@ def test_minimax_h3_t2va_matches_official_reference(
{
"task": "t2va",
"duration": DURATION_SECONDS,
"aspect_ratio": "16:9",
"audio_flow_shift": AUDIO_FLOW_SHIFT,
}
),
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import subprocess
import sys
def test_batched_runtime_import_does_not_require_optional_opus_dependency() -> None:
script = """
import builtins
real_import = builtins.__import__
def guarded_import(name, *args, **kwargs):
if name == "sphn":
raise ModuleNotFoundError("blocked optional dependency: sphn")
return real_import(name, *args, **kwargs)
builtins.__import__ = guarded_import
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import BatchedSessionManager
assert BatchedSessionManager is not None
"""
result = subprocess.run(
[sys.executable, "-c", script],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
@@ -0,0 +1,303 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
from types import SimpleNamespace
import numpy as np
import pytest
import torch
from vllm_omni.experimental.fullduplex.personaplex.policy import (
SILENCE_TOKENS,
SINE_TOKENS,
)
from vllm_omni.experimental.fullduplex.personaplex.stage0 import (
PersonaPlexStage0DuplexRuntime,
)
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
PersonaPlexTalkerForConditionalGeneration,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeCodec:
def __init__(self) -> None:
self.encode_calls = 0
self.reset_calls = 0
def streaming_init(self, batch_size: int) -> None:
assert batch_size == 1
def encode_frame(self, pcm: torch.Tensor) -> torch.Tensor:
assert pcm.shape == (1, 1920)
self.encode_calls += 1
return torch.full((1, 8), self.encode_calls, dtype=torch.long)
def reset_streaming(self) -> None:
self.reset_calls += 1
class _FakeTalker:
def __init__(self) -> None:
self.device = torch.device("cpu")
self.dtype = torch.float32
self.frame_calls: list[dict[str, torch.Tensor | None]] = []
def _build_prefill_embed(
self,
tokens,
offset,
span,
device,
silence=None,
user_sine=None,
):
del offset, silence, user_sine
values = tokens[:span].to(device=device, dtype=torch.float32)
return values[:, None].expand(-1, 4).contiguous()
def _build_frame_embed(
self,
text_token,
last_agent,
prev_agent,
device,
user_d0=None,
user_d1=None,
):
self.frame_calls.append(
{
"text_token": text_token.clone(),
"last_agent": None if last_agent is None else last_agent.clone(),
"prev_agent": None if prev_agent is None else prev_agent.clone(),
"user_d0": None if user_d0 is None else user_d0.clone(),
"user_d1": None if user_d1 is None else user_d1.clone(),
}
)
value = float(user_d0[0].item()) if user_d0 is not None else -1.0
return torch.full((1, 4), value, device=device)
def _duplex_info(*, seq: int, session_id: str = "session", incarnation: int = 1):
pcm = np.zeros(1920, dtype="<f4")
return {
"data_plane": True,
"session_id": session_id,
"incarnation": incarnation,
"epoch": 0,
"seq": seq,
"payload": {
"format": "pcm_f32le",
"sample_rate_hz": 24000,
"audio": base64.b64encode(pcm.tobytes()).decode("ascii"),
},
"runtime_config": {
"personaplex_model_path": "/unused",
"personaplex_voice_prompt": "NATF2.pt",
"personaplex_persona": "Be concise.",
},
}
def _runtime(*codecs: _FakeCodec) -> PersonaPlexStage0DuplexRuntime:
voice_embeddings = torch.arange(8, dtype=torch.float32).reshape(2, 1, 1, 4)
codec_args = {"codec": codecs[0]}
if len(codecs) > 1:
available = iter(codecs)
codec_args = {
"codec_factory": lambda: next(available),
"max_sessions": len(codecs),
}
return PersonaPlexStage0DuplexRuntime(
_FakeTalker(),
model_path="/unused",
device="cpu",
**codec_args,
tokenizer=lambda _text: [7, 8, 9],
voice_loader=lambda _voice: {
"embeddings": voice_embeddings,
"cache": torch.zeros((1, 17, 4), dtype=torch.long),
},
)
def test_first_append_prepends_voice_and_persona_once() -> None:
codec = _FakeCodec()
runtime = _runtime(codec)
first = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
second = runtime.prepare_append(_duplex_info(seq=2), prompt_len=18)
assert first.prefill_applied is True
assert first.prompt_offset == 0
assert first.user_codes.shape == (1, 8)
assert first.inputs_embeds.shape == (18, 4)
assert second.prefill_applied is False
assert second.prompt_offset == 17
assert second.user_codes.shape == (2, 8)
assert second.inputs_embeds.shape == (1, 4)
assert codec.encode_calls == 2
@pytest.mark.parametrize(
("append_count", "expected_tokens", "expected_provided"),
[
(
1,
[*SILENCE_TOKENS, 1, *SINE_TOKENS[1:]],
[False, *([True] * 15)],
),
(
2,
[*SILENCE_TOKENS, 2, *([1] * 7)],
[*([False] * 8), *([True] * 8)],
),
],
)
def test_prepare_append_preserves_native_depformer_teacher_forcing(
append_count: int,
expected_tokens: list[int],
expected_provided: list[bool],
) -> None:
runtime = _runtime(_FakeCodec())
result = None
for seq in range(1, append_count + 1):
result = runtime.prepare_append(_duplex_info(seq=seq), prompt_len=18)
assert result is not None
assert result.info_update["pplex_depformer_audio_tokens"].tolist() == expected_tokens
assert result.info_update["pplex_depformer_audio_provided"].tolist() == expected_provided
def test_frame_embed_uses_previous_effective_agent_frame() -> None:
fake_talker = SimpleNamespace(
config=SimpleNamespace(
num_audio_codebooks=16,
audio_vocab_size=2048,
),
input_embeddings=lambda stack: stack,
)
last_agent = torch.arange(10, 18)
embeds = PersonaPlexTalkerForConditionalGeneration._build_frame_embed(
fake_talker,
torch.tensor([3]),
last_agent,
torch.arange(8),
torch.device("cpu"),
)
assert embeds.shape == (1, 17)
assert torch.equal(embeds[0, 1:9], last_agent)
def test_repeated_append_identity_does_not_advance_codec() -> None:
codec = _FakeCodec()
runtime = _runtime(codec)
info = _duplex_info(seq=1)
first = runtime.prepare_append(info, prompt_len=18, request_id="req")
retry = runtime.prepare_append(info, prompt_len=18, request_id="req")
assert codec.encode_calls == 1
assert torch.equal(first.inputs_embeds, retry.inputs_embeds)
assert torch.equal(first.user_codes, retry.user_codes)
def test_next_append_uses_prior_sample_and_causally_delayed_user_frame() -> None:
runtime = _runtime(_FakeCodec())
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req")
first_agent = torch.arange(8, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(101), agent_codes=first_agent)
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req")
second_agent = torch.arange(10, 18, dtype=torch.long)
runtime.record_sample(request_id="req", text_token=torch.tensor(102), agent_codes=second_agent)
runtime.prepare_append(_duplex_info(seq=3), prompt_len=20, request_id="req")
silence = torch.tensor(SILENCE_TOKENS, dtype=torch.long)
sine = torch.tensor(SINE_TOKENS, dtype=torch.long)
first_call = runtime.stage_model.frame_calls[0]
assert all(torch.equal(first_call[key], silence) for key in ("last_agent", "prev_agent"))
assert all(torch.equal(first_call[key], sine) for key in ("user_d0", "user_d1"))
second_call = runtime.stage_model.frame_calls[1]
assert second_call["text_token"].tolist() == [101]
expected_first_effective = torch.cat([first_agent[:1], silence[1:]])
assert torch.equal(second_call["last_agent"], expected_first_effective)
assert torch.equal(second_call["prev_agent"], expected_first_effective)
assert torch.equal(second_call["user_d0"], torch.full((8,), 1, dtype=torch.long))
assert torch.equal(second_call["user_d1"], sine)
third_call = runtime.stage_model.frame_calls[2]
assert third_call["text_token"].tolist() == [102]
assert torch.equal(third_call["last_agent"], second_agent)
assert torch.equal(third_call["prev_agent"], second_agent)
assert torch.equal(third_call["user_d0"], torch.full((8,), 2, dtype=torch.long))
assert torch.equal(third_call["user_d1"], torch.full((8,), 1, dtype=torch.long))
def test_decoded_pcm_is_writable_for_torch_zero_copy() -> None:
pcm = PersonaPlexStage0DuplexRuntime._decode_pcm(_duplex_info(seq=1)["payload"])
assert pcm.flags.writeable
def _prepare_two_sessions(
runtime: PersonaPlexStage0DuplexRuntime,
) -> tuple[object, object, object, object]:
first_1 = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
second_1 = runtime.prepare_append(
_duplex_info(seq=1, session_id="other", incarnation=2),
prompt_len=18,
)
first_2 = runtime.prepare_append(_duplex_info(seq=2), prompt_len=19)
second_2 = runtime.prepare_append(
_duplex_info(seq=2, session_id="other", incarnation=2),
prompt_len=19,
)
return first_1, second_1, first_2, second_2
def test_live_sessions_keep_independent_streaming_encoders() -> None:
first_codec = _FakeCodec()
second_codec = _FakeCodec()
runtime = _runtime(first_codec, second_codec)
first_1, second_1, first_2, second_2 = _prepare_two_sessions(runtime)
assert first_1.user_codes[:, 0].tolist() == [1]
assert second_1.user_codes[:, 0].tolist() == [1]
assert first_2.user_codes[:, 0].tolist() == [1, 2]
assert second_2.user_codes[:, 0].tolist() == [1, 2]
def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None:
runtime = _runtime(_FakeCodec(), _FakeCodec())
_prepare_two_sessions(runtime)
with pytest.raises(RuntimeError, match="capacity 2"):
runtime.prepare_append(
_duplex_info(seq=1, session_id="third", incarnation=3),
prompt_len=18,
)
def test_close_session_resets_and_reuses_released_codec() -> None:
first_codec = _FakeCodec()
second_codec = _FakeCodec()
runtime = _runtime(first_codec, second_codec)
_prepare_two_sessions(runtime)
runtime.close_session("session", 1)
assert first_codec.reset_calls == 1
assert second_codec.reset_calls == 0
replacement = runtime.prepare_append(
_duplex_info(seq=1, session_id="replacement", incarnation=4),
prompt_len=18,
)
assert replacement.user_codes[:, 0].tolist() == [3]
@@ -0,0 +1,316 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.model_executor.models.personaplex import (
personaplex_code2wav,
personaplex_mimi,
)
from vllm_omni.model_executor.models.personaplex.personaplex_code2wav import (
PersonaPlexCode2Wav,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _FakeStreamingMimi(nn.Module):
def __init__(self, stream_value: int = 0) -> None:
super().__init__()
self.stream_value = stream_value
self.decode_frame_calls = 0
self.reset_calls = 0
def decode(self, codes: torch.Tensor, return_dict: bool = True):
assert return_dict
frames = int(codes.shape[-1])
return SimpleNamespace(audio_values=torch.arange(frames * 4, dtype=torch.float32).reshape(1, 1, -1))
def streaming_init(self, batch_size: int) -> None:
assert batch_size == 1
def decode_frame(self, codes: torch.Tensor) -> torch.Tensor:
assert codes.shape == (1, 2)
self.decode_frame_calls += 1
value = self.stream_value + self.decode_frame_calls
return torch.full((1, 4), float(value), dtype=torch.float32)
def reset_streaming(self) -> None:
self.reset_calls += 1
class _FakeChunkedMimi(_FakeStreamingMimi):
def __init__(self) -> None:
super().__init__()
self.decode_frames_calls = 0
self.decode_frames_shapes: list[tuple[int, ...]] = []
def decode_frames(self, codes: torch.Tensor) -> torch.Tensor:
self.decode_frames_calls += 1
self.decode_frames_shapes.append(tuple(codes.shape))
return torch.arange(codes.shape[-1] * 4, dtype=torch.float32).reshape(1, -1)
class _FakeMimiModel(nn.Module):
def __init__(self, _config) -> None:
super().__init__()
self.encoder_transformer = nn.Linear(1, 1, bias=False)
self.decoder_transformer = nn.Linear(1, 1, bias=False)
self.quantizer = nn.Linear(1, 1, bias=False)
self.encoder = SimpleNamespace(layers=[])
self.downsample = SimpleNamespace(conv=nn.Conv1d(1, 1, 1))
self.upsample = SimpleNamespace(conv=nn.ConvTranspose1d(1, 1, 1))
self.decoder = SimpleNamespace(layers=[])
def load_state_dict(self, _state_dict, strict: bool = True):
assert not strict
return SimpleNamespace(
missing_keys=[
"encoder_transformer.weight",
"decoder_transformer.weight",
],
unexpected_keys=[],
)
class _FakeMimiTransformer(nn.Module):
def __init__(self) -> None:
super().__init__()
self.weight = nn.Parameter(torch.zeros(1))
def load_weights(self, _state_dict, _prefix: str) -> int:
return 80
def test_moshi_checkpoint_mapping_drops_replaced_transformer_weights(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mapper = getattr(personaplex_mimi, "_map_moshi_codec_weights", None)
assert callable(mapper), "PersonaPlex Mimi must load its bundled codec weights without Hugging Face"
source = {
"encoder.model.3.conv.conv.weight": torch.ones(1),
"downsample.conv.conv.conv.weight": torch.ones(1),
"quantizer.rvq_first.vq.layers.0._codebook.embedding_sum": torch.ones(1),
"quantizer.rvq_rest.vq.layers.3._codebook._initialized": torch.ones(1),
"encoder_transformer.transformer.layers.0.norm1.weight": torch.ones(1),
}
mapped = mapper(source)
assert set(mapped) == {
"encoder.layers.3.conv.weight",
"downsample.conv.weight",
"quantizer.semantic_residual_vector_quantizer.layers.0.codebook.embed_sum",
"quantizer.acoustic_residual_vector_quantizer.layers.3.codebook.initialized",
}
assert mapped["encoder.layers.3.conv.weight"] is source["encoder.model.3.conv.conv.weight"]
monkeypatch.setattr("transformers.MimiConfig", lambda: object())
monkeypatch.setattr("transformers.MimiModel", _FakeMimiModel)
monkeypatch.setattr("safetensors.torch.load_file", lambda *_args, **_kwargs: {})
monkeypatch.setattr(personaplex_mimi, "_MimiStreamingTransformer", _FakeMimiTransformer)
codec = personaplex_mimi.PersonaPlexMimiCodec(
checkpoint="/unused",
device="cpu",
)
assert not hasattr(codec.model, "encoder_transformer")
assert not hasattr(codec.model, "decoder_transformer")
assert not any(
name.startswith(("model.encoder_transformer.", "model.decoder_transformer."))
for name, _ in codec.named_parameters()
)
def test_mimi_full_stream_reset_reuses_all_state_storage_and_clears_offsets() -> None:
codec = personaplex_mimi.PersonaPlexMimiCodec.__new__(personaplex_mimi.PersonaPlexMimiCodec)
nn.Module.__init__(codec)
codec.device = torch.device("cpu")
codec.dtype = torch.float32
codec._enc_stages = [("conv", personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3)))]
codec._dec_stages = []
codec._downsample = personaplex_mimi._StreamConv1d(nn.Conv1d(1, 1, 3))
codec._upsample = personaplex_mimi._StreamConvTr1d(nn.ConvTranspose1d(1, 1, 3))
codec.encoder_transformer = personaplex_mimi._MimiStreamingTransformer(
num_layers=1,
dim=4,
num_heads=1,
context=3,
)
codec.decoder_transformer = personaplex_mimi._MimiStreamingTransformer(
num_layers=1,
dim=4,
num_heads=1,
context=3,
)
codec.streaming_init(batch_size=2)
conv_states = list(codec._conv_states())
conv_buffers = [
state.prev if isinstance(state, personaplex_mimi._StreamConv1d) else state.partial for state in conv_states
]
transformers = [codec.encoder_transformer, codec.decoder_transformer]
kv_states = [kv for transformer in transformers for kv in transformer._kv]
state_tensors = [
*conv_buffers,
*(kv.cache for kv in kv_states),
*(kv.end_offset for kv in kv_states),
*(kv.start_offset for kv in kv_states),
*(transformer._offset for transformer in transformers),
]
for tensor in state_tensors:
tensor.fill_(1)
for state in conv_states:
state._fresh.zero_()
storage = [tensor.data_ptr() for tensor in state_tensors]
codec.reset_streaming()
assert storage == [tensor.data_ptr() for tensor in state_tensors]
cleared = [
*conv_buffers,
*(kv.end_offset for kv in kv_states),
*(kv.start_offset for kv in kv_states),
*(transformer._offset for transformer in transformers),
]
assert all(not tensor.any() for tensor in cleared)
assert all(state._fresh.all() for state in conv_states)
def _model(*, max_sessions: int = 1) -> tuple[PersonaPlexCode2Wav, _FakeStreamingMimi]:
mimi_config = SimpleNamespace(num_codebooks=2, sample_rate=24000, samples_per_frame=4, mimi_name=None)
config = SimpleNamespace(mimi_config=mimi_config, mimi_name=None)
vllm_config = SimpleNamespace(
model_config=SimpleNamespace(
model="/unused",
hf_config=config,
duplex_max_sessions=max_sessions,
),
device_config=SimpleNamespace(device="cpu"),
)
model = PersonaPlexCode2Wav(vllm_config=vllm_config)
mimi = _FakeStreamingMimi()
model.mimi = mimi
model._mimi_device = torch.device("cpu")
return model, mimi
def _codes(frames: int, *, start: int = 0) -> torch.Tensor:
return torch.stack([torch.arange(start + offset, start + offset + frames) for offset in (0, 100)]).reshape(-1)
def _audio(output) -> torch.Tensor:
return output.multimodal_outputs["model_outputs"][0]
@pytest.mark.parametrize("second_codes", [_codes(3), _codes(1, start=100)])
def test_resumable_codes_emit_only_new_pcm(second_codes: torch.Tensor) -> None:
model, mimi = _model()
first = model(input_ids=_codes(2), request_ids=["req"])
second = model(input_ids=second_codes, request_ids=["req"])
assert _audio(first).numel() == 8
assert _audio(second).numel() == 4
assert mimi.decode_frame_calls == 3
@pytest.mark.parametrize(
("frames", "expected_shapes"),
[
(5, [(1, 2, 5)]),
(12, [(1, 2, 5), (1, 2, 5), (1, 2, 2)]),
],
)
def test_streaming_decode_uses_bounded_multi_frame_calls(
frames: int,
expected_shapes: list[tuple[int, ...]],
) -> None:
model, _ = _model()
mimi = _FakeChunkedMimi()
model.mimi = mimi
output = model(input_ids=_codes(frames), request_ids=["req"])
assert _audio(output).numel() == frames * 4
assert mimi.decode_frames_shapes == expected_shapes
assert mimi.decode_frame_calls == 0
def test_profile_inputs_skip_decode_while_malformed_online_input_warns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model, mimi = _model()
runtime_info = [{"meta": {"personaplex_dummy_profile": True}}]
warnings: list[tuple] = []
monkeypatch.setattr(personaplex_code2wav.logger, "warning", lambda *args: warnings.append(args))
output = model(
input_ids=torch.arange(3),
runtime_additional_information=runtime_info,
)
assert _audio(output).numel() == 0
assert mimi.decode_frame_calls == 0
assert warnings == []
output = model(input_ids=torch.arange(3))
assert _audio(output).numel() == 0
assert mimi.decode_frame_calls == 0
assert len(warnings) == 1
assert "not divisible by" in warnings[0][0]
assert model.get_dummy_runtime_additional_information(2) == [
{"meta": {"personaplex_dummy_profile": True}},
{"meta": {"personaplex_dummy_profile": True}},
]
def test_request_id_falls_back_to_runtime_information() -> None:
model, _ = _model()
info = [{"request_id": "runtime-req"}]
model(input_ids=_codes(2), runtime_additional_information=info)
second = model(input_ids=_codes(3), runtime_additional_information=info)
assert _audio(second).numel() == 4
def test_decoder_slot_lifecycle_isolated_capacity_and_reuse() -> None:
model, _ = _model(max_sessions=2)
first = _FakeStreamingMimi(stream_value=10)
second = _FakeStreamingMimi(stream_value=20)
model._set_mimi_codecs([first, second])
assert model._max_codec_sessions == 2
initial = model(
input_ids=torch.cat([_codes(1), _codes(1)]),
request_ids=["first", "second"],
seq_token_counts=[2, 2],
)
continued = model(
input_ids=torch.cat([_codes(1, start=10), _codes(1, start=20)]),
request_ids=["first", "second"],
seq_token_counts=[2, 2],
)
initial_audio = initial.multimodal_outputs["model_outputs"]
continued_audio = continued.multimodal_outputs["model_outputs"]
assert initial_audio[0].tolist() == [11.0] * 4
assert initial_audio[1].tolist() == [21.0] * 4
assert continued_audio[0].tolist() == [12.0] * 4
assert continued_audio[1].tolist() == [22.0] * 4
assert model._request_codec_slots == {"first": 0, "second": 1}
with pytest.raises(RuntimeError, match="decoder capacity 2 is exhausted"):
model._codec_for_request("overflow")
model.on_requests_finished(["first"])
assert first.reset_calls == 1
assert second.reset_calls == 0
replacement = model(input_ids=_codes(1), request_ids=["replacement"])
assert _audio(replacement).tolist() == [13.0] * 4
@@ -0,0 +1,364 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import base64
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from vllm.sampling_params import SamplingParams
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
from vllm_omni.config.stage_config import (
load_deploy_config,
merge_pipeline_deploy,
)
from vllm_omni.experimental.fullduplex.engine.duplex_runtime import (
DuplexInputMode,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
from vllm_omni.experimental.fullduplex.personaplex.data_plane import (
PersonaPlexDataPlaneContext,
PersonaPlexDataPlaneSession,
)
from vllm_omni.experimental.fullduplex.personaplex.input import (
PersonaPlexPcmAppendBuffer,
)
from vllm_omni.experimental.fullduplex.personaplex.runtime_extension import (
PersonaPlexDuplexRuntimeExtension,
)
from vllm_omni.experimental.fullduplex.personaplex.serving_adapter import (
PersonaPlexServingRuntimeAdapter,
)
from vllm_omni.model_executor.models.personaplex.pipeline import (
PERSONAPLEX_PIPELINE,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _pcm_payload(samples: np.ndarray, *, sample_rate_hz: int = 24000) -> dict[str, object]:
samples = np.ascontiguousarray(samples, dtype="<f4")
return {
"type": "audio",
"format": "pcm_f32le",
"sample_rate_hz": sample_rate_hz,
"audio": base64.b64encode(samples.tobytes()).decode("ascii"),
}
def _audio_client(
frame_count: int = 10,
*,
voiced_frames: int = 0,
) -> SimpleNamespace:
frames = np.full((frame_count, e2e_driver.FRAME_SAMPLES), 7, dtype="<i2")
frames[:voiced_frames] = 1000
raw = frames.tobytes()
event = {
"type": "response.audio.delta",
"response_id": "response-1",
"sample_rate_hz": e2e_driver.SAMPLE_RATE_HZ,
"metadata": {
"vllm_omni": {
"runtime_impl": "scheduler_data_plane",
"uses_model_runner_scheduler": True,
"runner_kv_backed": True,
}
},
}
events = SimpleNamespace(
events=[event],
response_audio={"response-1": [raw]},
audio_bytes=lambda: raw,
)
return SimpleNamespace(events=events)
def test_realtime_audio_frame_stats_separate_voiced_and_silent_frames() -> None:
silent = np.zeros(e2e_driver.FRAME_SAMPLES, dtype="<i2")
voiced = np.full(e2e_driver.FRAME_SAMPLES, 1000, dtype="<i2")
stats = e2e_driver._audio_frame_stats(
silent.tobytes() + voiced.tobytes(),
input_frames=3,
voiced_frame_rms_threshold=1e-4,
)
assert stats == {
"output_frames": 2,
"voiced_frames": 1,
"silent_frames": 1,
"frame_deficit": 1,
"frame_coverage_ratio": pytest.approx(2 / 3),
}
def test_realtime_audio_frame_stats_reject_partial_codec_frame() -> None:
partial_frame = np.zeros(e2e_driver.FRAME_SAMPLES - 1, dtype="<i2")
with pytest.raises(AssertionError, match="whole 80 ms codec frames"):
e2e_driver._audio_frame_stats(
partial_frame.tobytes(),
input_frames=1,
voiced_frame_rms_threshold=1e-4,
)
@pytest.mark.parametrize("frame_count", [10, 40])
def test_e2e_driver_rejects_inaudible_sessions(frame_count: int) -> None:
args = SimpleNamespace(
max_frame_deficit=4,
voiced_frame_rms_threshold=1e-3,
min_voiced_frames=5,
)
with pytest.raises(AssertionError, match="audible speech"):
e2e_driver._session_result(
_audio_client(frame_count),
input_frames=frame_count,
args=args,
minimum_chunks=1,
)
def test_e2e_driver_uses_absolute_audible_floor() -> None:
args = SimpleNamespace(
max_frame_deficit=4,
voiced_frame_rms_threshold=1e-3,
min_voiced_frames=5,
)
e2e_driver._session_result(
_audio_client(200, voiced_frames=5),
input_frames=200,
args=args,
minimum_chunks=1,
)
def test_e2e_driver_defaults_replacement_to_full_input() -> None:
args = e2e_driver.parse_args(
[
"--model",
"/model",
"--input-wav",
"/input.wav",
]
)
assert args.replacement_frames == 0
assert args.min_voiced_frames == 5
def test_e2e_driver_records_and_validates_input_sha256(tmp_path: Path) -> None:
input_wav = tmp_path / "input.wav"
input_wav.write_bytes(b"fixture-wav")
identity = e2e_driver._input_identity(
input_wav,
expected_sha256="bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
)
assert identity == {
"path": str(input_wav.resolve()),
"sha256": "bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
}
def test_e2e_driver_rejects_unexpected_input_sha256(tmp_path: Path) -> None:
input_wav = tmp_path / "input.wav"
input_wav.write_bytes(b"fixture-wav")
with pytest.raises(ValueError, match="input WAV SHA-256 mismatch"):
e2e_driver._input_identity(input_wav, expected_sha256="0" * 64)
@pytest.mark.parametrize(
("attribute", "expected"),
[
(
"duplex_runtime_extension",
"vllm_omni.experimental.fullduplex.personaplex.runtime_extension.PersonaPlexDuplexRuntimeExtension",
),
(
"duplex_serving_adapter",
"vllm_omni.experimental.fullduplex.personaplex.serving_adapter.PersonaPlexServingRuntimeAdapter",
),
],
)
def test_personaplex_pipeline_registers_unified_duplex_components(
attribute: str,
expected: str,
) -> None:
assert PERSONAPLEX_PIPELINE.duplex_control_enabled is True
assert getattr(PERSONAPLEX_PIPELINE, attribute) == expected
@pytest.mark.parametrize(
("engine_arg", "expected"),
[
("skip_tokenizer_init", True),
("enable_prefix_caching", False),
],
)
def test_personaplex_stage_engine_args(
engine_arg: str,
expected: bool,
) -> None:
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
stages = merge_pipeline_deploy(
PERSONAPLEX_PIPELINE,
load_deploy_config(deploy_path),
)
assert [stage.yaml_engine_args.get(engine_arg) for stage in stages] == [
expected,
expected,
]
def test_personaplex_duplex_capacity_is_propagated_to_all_model_stages() -> None:
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
deploy = load_deploy_config(deploy_path)
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, deploy)
assert deploy.duplex_session.max_sessions == 2
assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2]
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
def test_personaplex_capabilities_are_honest() -> None:
single = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=1)
multi = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=2)
assert multi.implementation_level == "model_native_duplex"
assert multi.input_modes == ["append_audio_chunk"]
assert multi.chunk_period_ms == 80
assert single.supports_multi_session is False
assert multi.supports_multi_session is True
assert multi.supports_multi_session_same_replica is True
assert multi.supports_barge_in is False
def test_pcm_buffer_emits_one_80ms_frame_transactionally() -> None:
buffer = PersonaPlexPcmAppendBuffer()
reservation = buffer.prepare_append(
_pcm_payload(np.arange(1920, dtype=np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=True,
)
assert reservation is not None
assert reservation.byte_count == 1920 * 4
assert buffer.pending_byte_count == 0
reservation.rollback()
assert buffer.pending_byte_count == 1920 * 4
def test_pcm_buffer_rejects_changed_sample_rate() -> None:
buffer = PersonaPlexPcmAppendBuffer()
assert (
buffer.prepare_append(
_pcm_payload(np.zeros(100, np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=False,
)
is None
)
with pytest.raises(ValueError, match="sample_rate"):
buffer.prepare_append(
_pcm_payload(np.zeros(100, np.float32), sample_rate_hz=16000),
operation_id="op-2",
chunk_period_ms=80,
allow_emit=False,
)
def test_pcm_buffer_rejects_non_finite_samples() -> None:
buffer = PersonaPlexPcmAppendBuffer()
with pytest.raises(ValueError, match="finite"):
buffer.prepare_append(
_pcm_payload(np.array([0.0, np.nan], np.float32)),
operation_id="op-1",
chunk_period_ms=80,
allow_emit=False,
)
def test_runtime_extension_builds_one_frame_scheduler_append() -> None:
extension = PersonaPlexDuplexRuntimeExtension()
defaults = (
SamplingParams(temperature=0.8, top_k=10, max_tokens=10),
SamplingParams(max_tokens=1024),
)
configured = extension.configure_sampling_params(runtime_config={}, defaults=defaults)
assert configured[0].temperature == 0.0
assert configured[0].top_k == 1
assert configured[0].max_tokens == 1
assert configured[1] is defaults[1]
plan = extension.plan_append(
request_id="req",
fence=DuplexFence("session", incarnation=2, epoch=3),
session_config={"instructions": "Be concise."},
runtime_config={"personaplex_prefill_slots": 4},
seq=1,
turn_seq=1,
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
payload=_pcm_payload(np.zeros(1920, np.float32)),
final=False,
sampling_params=configured[0],
)
assert len(plan.prompt["prompt_token_ids"]) == 5
duplex = plan.prompt["model_intermediate_buffer"]["duplex"]
assert duplex["session_id"] == "session"
assert duplex["incarnation"] == 2
assert duplex["epoch"] == 3
assert duplex["seq"] == 1
assert duplex["data_plane"] is True
def test_projector_emits_only_cumulative_audio_and_text_suffixes() -> None:
encoded_sizes: list[int] = []
def encode_audio(audio, _sample_rate, _response_format, _speed):
size = int(np.asarray(audio, dtype=np.float32).size)
encoded_sizes.append(size)
return f"audio-{size}"
projector = PersonaPlexDataPlaneSession(encode_audio)
projector.begin_request("req")
context = PersonaPlexDataPlaneContext(response_format="wav")
first = SimpleNamespace(
request_id="req",
outputs=[SimpleNamespace(text="he", multimodal_output={})],
multimodal_output={"model_outputs": np.arange(4, dtype=np.float32), "sr": 24000},
finished=False,
)
second = SimpleNamespace(
request_id="req",
outputs=[SimpleNamespace(text="hello", multimodal_output={})],
multimodal_output={"model_outputs": np.arange(6, dtype=np.float32), "sr": 24000},
finished=False,
)
projected = list(
projector.project(
{"data_plane_outputs": [first, second]},
context=context,
)
)
assert encoded_sizes == [4, 2]
assert [item["text"] for item in projected] == ["he", "llo"]
assert [item["audio_data"] for item in projected] == ["audio-4", "audio-2"]
assert all(item["sample_rate_hz"] == 24000 for item in projected)
assert all(item["end_of_turn"] is False for item in projected)
@@ -0,0 +1,122 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Contract tests for the fullduplex core (PR #3907 alignment).
Covers the DuplexFence identity value, the session identity rules (epoch only
advances on barge-in, response_index once per begin_response), stale-output
dropping in the turn lifecycle. The lockstep (single-eternal-response)
lifecycle is model-owned (see
tests/e2e/features/fullduplex/test_personaplex_adapter.py).
"""
import asyncio
import dataclasses
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.adapter import (
DuplexAdapter,
DuplexCapability,
OutputChunk,
)
from vllm_omni.experimental.fullduplex.core.runtime import DuplexRuntime
from vllm_omni.experimental.fullduplex.core.session import (
DuplexSession,
DuplexSessionConfig,
DuplexState,
)
from vllm_omni.experimental.fullduplex.engine.messages import DuplexFence
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_fence_is_frozen_value_identity():
f = DuplexFence(session_id="s1")
assert (f.epoch, f.turn_id, f.response_seq) == (0, 0, 0)
with pytest.raises(dataclasses.FrozenInstanceError):
f.epoch = 1 # type: ignore[misc]
assert DuplexFence("s1", 1, 2, 3) == DuplexFence("s1", 1, 2, 3)
assert DuplexFence("s1", 1, 2, 3) != DuplexFence("s1", 2, 2, 3)
def test_session_identity_rules():
s = DuplexSession(session_id="test", config=DuplexSessionConfig())
r1, e1 = s.begin_response()
r2, e2 = s.begin_response()
assert r2 == r1 + 1, "response index advances exactly once per begin"
assert e1 == e2 == 0, "epoch does not move on response begin"
s.end_response()
assert s.epoch == 0, "epoch does not move on response end"
new_epoch = s.barge_in()
assert new_epoch == 1, "epoch advances only on barge-in"
assert s.is_stale(e1) and not s.is_stale(new_epoch)
class _TurnAdapter(DuplexAdapter):
"""Emits chunks until cancelled; counts respond() lifecycles."""
def __init__(self):
self.responses = 0
self.barge_ins = 0
def capabilities(self) -> DuplexCapability:
return DuplexCapability(
input_modalities=frozenset({"audio"}),
output_modalities=frozenset({"audio"}),
proactive=True,
)
async def on_input(self, session, modality, data) -> None:
pass
def should_respond(self, session) -> bool:
return True
async def on_barge_in(self, session) -> None:
self.barge_ins += 1
async def respond(self, session):
self.responses += 1
for i in range(100):
await asyncio.sleep(0.001)
yield OutputChunk("audio", i)
async def _drive(runtime, events):
async def gen():
for e in events:
await asyncio.sleep(0.005)
yield e
out = []
async def emit(e):
out.append(e)
await runtime.run(gen(), emit)
return out
def test_turn_mode_supersede_and_stale_drop():
adapter = _TurnAdapter()
session = DuplexSession(session_id="test", config=DuplexSessionConfig(proactive=True))
runtime = DuplexRuntime(session, adapter)
out = asyncio.run(
_drive(
runtime,
[
{"type": ev.RESPONSE_CREATE},
{"type": ev.RESPONSE_CREATE}, # supersedes the first
{"type": ev.CLOSE},
],
)
)
assert adapter.responses == 2
assert adapter.barge_ins == 1, "superseding an in-flight response barges in"
created = [e for e in out if e["type"] == ev.RESPONSE_CREATED]
done = [e for e in out if e["type"] == ev.RESPONSE_DONE]
assert len(created) == 2
assert len(done) == 1, "stale first response must not emit done"
assert done[0]["response_index"] == created[1]["response_index"]
assert session.state is DuplexState.CLOSED
@@ -0,0 +1,262 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""PersonaPlex adapter driven through the model-owned lockstep runtime.
PersonaPlexDuplexRuntime starts ONE response for the whole session, streams one
agent frame per user frame, and drains on close (core stays verbatim upstream;
the lockstep lifecycle is model policy) — all with a GPU-free stub stepper.
"""
import asyncio
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.core import protocol as ev
from vllm_omni.experimental.fullduplex.core.session import (
DuplexSession,
DuplexSessionConfig,
DuplexState,
)
from vllm_omni.experimental.fullduplex.personaplex.adapter import (
PersonaPlexDuplexAdapter,
PersonaPlexDuplexRuntime,
)
from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class StubStepper:
"""Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds."""
sample_rate = 24000
frame_size = FRAME_SIZE
def __init__(self) -> None:
self.opened: tuple[str | None, str | None] | None = None
self.steps = 0
def open_session(self, voice_prompt=None, persona=None) -> None:
self.opened = (voice_prompt, persona)
self.steps = 0
def step(self, user_pcm):
assert user_pcm.shape[0] == self.frame_size
self.steps += 1
i = self.steps
return FrameOutput(
audio=np.full(self.frame_size, float(i), dtype=np.float32),
text=(f"t{i}" if i % 2 else None),
)
class RecordingAdapter(PersonaPlexDuplexAdapter):
def __init__(self, stepper: StubStepper) -> None:
super().__init__(stepper)
self.closed = False
async def on_close(self, session: DuplexSession) -> None:
self.closed = True
await super().on_close(session)
def _collector():
out: list[dict] = []
async def emit(event: dict) -> None:
out.append(event)
return out, emit
async def _feed(events):
for e in events:
yield e
async def _raising_feed(frame):
yield {"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}
await asyncio.sleep(0)
raise RuntimeError("input failed")
def _respond_tasks() -> list[asyncio.Task]:
current = asyncio.current_task()
return [
task
for task in asyncio.all_tasks()
if task is not current and "_respond_once" in getattr(task.get_coro(), "__qualname__", "")
]
def _session():
cfg = DuplexSessionConfig(
input_modalities=("audio",),
output_modalities=("audio", "text"),
)
return DuplexSession("s", cfg)
@pytest.mark.asyncio
async def test_lockstep_one_agent_frame_per_user_frame():
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub, voice_prompt="NATF2.pt", persona="be terse")
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame} for _ in range(3)]
events.append({"type": ev.CLOSE})
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
# exactly one response for the whole session, drained cleanly on close
assert types.count(ev.RESPONSE_CREATED) == 1
assert types.count(ev.RESPONSE_DONE) == 1
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
text = [e["data"] for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "text"]
assert len(audio) == 3 # one agent frame per user frame
assert [int(e["data"][0]) for e in audio] == [1, 2, 3] # lockstep order
assert text == ["t1", "t3"]
assert stub.steps == 3
assert stub.opened == ("NATF2.pt", "be terse")
@pytest.mark.asyncio
async def test_unaligned_chunks_are_reframed_to_80ms():
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
# 2.5 frames in one chunk + 0.5 frame in another => 3 whole frames
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE * 5 // 2, dtype=np.float32)},
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.zeros(FRAME_SIZE // 2, dtype=np.float32)},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 3
assert stub.steps == 3
@pytest.mark.asyncio
async def test_close_without_input_is_clean():
adapter = PersonaPlexDuplexAdapter(StubStepper())
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
await rt.run(_feed([{"type": ev.CLOSE}]), emit)
# no input -> response never started -> no created/done, no error
assert not any(e["type"] == ev.ERROR for e in out)
assert not any(e["type"] == ev.RESPONSE_CREATED for e in out)
@pytest.mark.asyncio
async def test_iterator_eof_without_close_drains_and_stops():
# Transport drop: the input iterator ends WITHOUT an explicit CLOSE event.
# The runtime must still signal on_close so the eternal response drains
# instead of blocking on its inbox forever.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}]
await asyncio.wait_for(rt.run(_feed(events), emit), timeout=5.0)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_DONE) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_input_exception_closes_response_and_runtime() -> None:
stub = StubStepper()
adapter = RecordingAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
with pytest.raises(RuntimeError, match="input failed"):
await rt.run(_raising_feed(frame), emit)
leaked = _respond_tasks()
for task in leaked:
task.cancel()
if leaked:
await asyncio.gather(*leaked, return_exceptions=True)
assert adapter.closed is True
assert rt.session.state is DuplexState.CLOSED
assert not leaked
@pytest.mark.asyncio
async def test_default_session_config_gets_lockstep_lifecycle():
# The lifecycle comes from the model-owned runtime class, not a session
# flag: a plain default-constructed session config runs lockstep.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
session = DuplexSession(
"s",
DuplexSessionConfig(input_modalities=("audio",), output_modalities=("audio", "text")),
)
rt = PersonaPlexDuplexRuntime(session, adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame}, {"type": ev.CLOSE}]
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_CREATED) == 1
assert types.count(ev.RESPONSE_DONE) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_partial_tail_is_padded_and_stepped_on_close():
# A trailing sub-frame chunk is zero-padded and stepped at close (same
# contract as PersonaPlexSession.flush) rather than silently dropped.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
rt = PersonaPlexDuplexRuntime(_session(), adapter)
out, emit = _collector()
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": np.ones(FRAME_SIZE // 2, dtype=np.float32)},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 1
assert stub.steps == 1
@pytest.mark.asyncio
async def test_single_eternal_response_across_barge_in():
# Moved from the core contract tests when the lockstep lifecycle became
# model-owned: ONE response for the whole session, RESPONSE_CANCEL bumps the
# epoch fence (playback cursor) but the stream keeps flowing, and close
# drains via on_close.
stub = StubStepper()
adapter = PersonaPlexDuplexAdapter(stub)
session = _session()
rt = PersonaPlexDuplexRuntime(session, adapter)
out, emit = _collector()
frame = np.zeros(FRAME_SIZE, dtype=np.float32)
events = [
{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame},
{"type": ev.RESPONSE_CANCEL}, # barge-in: epoch bumps, stream continues
{"type": ev.INPUT_APPEND, "modality": "audio", "data": frame},
{"type": ev.CLOSE},
]
await rt.run(_feed(events), emit)
types = [e["type"] for e in out]
assert types.count(ev.RESPONSE_CREATED) == 1, "exactly one eternal response"
assert types.count(ev.RESPONSE_DONE) == 1
audio = [e for e in out if e["type"] == ev.RESPONSE_DELTA and e["modality"] == "audio"]
assert len(audio) == 2, "stream keeps flowing across barge-in"
assert session.epoch == 1, "cancel still advanced the epoch fence"
assert stub.steps == 2
@@ -0,0 +1,178 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""GPU-free tests for the batched PersonaPlex slot manager (stub engine)."""
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, PrefillStep
from vllm_omni.experimental.fullduplex.personaplex.serving.batched import (
BatchedSessionManager,
SlotPhase,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
FRAME = 4
class StubEngine:
"""Records the slot-API call sequence; echoes each row's pcm as its output."""
frame_size = FRAME
def __init__(self, batch_size: int = 4, prefill_len: int = 2) -> None:
self.config = SimpleNamespace(batch_size=batch_size)
self.prefill_len = prefill_len
self.recycled: list[int] = []
self.voice_ended: list[int] = []
self.prefill_done: list[int] = []
self.steps: list[tuple[np.ndarray, dict | None]] = []
def prefill_steps(self, persona=None):
steps = [PrefillStep("sacrifice"), PrefillStep("voice", embedding=None), PrefillStep("voice_end")]
steps += [PrefillStep("tokens", moshi_tokens=None, text_token=3, user_sine=True)] * self.prefill_len
return steps
def recycle_slot(self, b):
self.recycled.append(b)
def voice_end(self, b):
self.voice_ended.append(b)
def end_prefill(self, b):
self.prefill_done.append(b)
def step_batch(self, pcm, prefill=None):
self.steps.append((pcm.copy(), dict(prefill) if prefill else None))
return [FrameOutput(audio=pcm[b].copy(), text=None) for b in range(self.config.batch_size)]
def test_all_idle_does_not_step():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
assert mgr.tick() is False
assert eng.steps == []
def test_pristine_slot_is_live_immediately():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire()
assert ready_now is True
assert slot.phase is SlotPhase.LIVE
assert eng.recycled == []
def test_custom_persona_prefills_and_recycles_on_tick_thread():
eng = StubEngine(prefill_len=2)
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire(persona="custom")
assert ready_now is False
assert slot.phase is SlotPhase.PREFILL
assert eng.recycled == [] # deferred: reset must run between engine steps
ready = []
slot.on_ready = lambda: ready.append(True)
mgr.tick() # recycle + sacrifice
assert eng.recycled == [slot.index]
assert eng.steps[-1][1][slot.index].kind == "sacrifice"
mgr.tick() # voice frame
assert eng.steps[-1][1][slot.index].kind == "voice"
assert eng.voice_ended == []
mgr.tick() # voice_end executed BETWEEN ticks, then first tokens frame
assert eng.voice_ended == [slot.index]
assert eng.steps[-1][1][slot.index].kind == "tokens"
mgr.tick() # second tokens frame
assert not ready
mgr.tick() # prefill exhausted -> live
assert eng.prefill_done == [slot.index]
assert slot.phase is SlotPhase.LIVE
assert ready == [True]
def test_pristine_slot_after_ticking_requires_recycle():
# step_batch advances EVERY row each tick, so a pristine idle row that has
# lived through ticks has drifted from its boot prefill and must be
# recycled before a client may join it.
eng = StubEngine()
mgr = BatchedSessionManager(eng)
first, ready_now = mgr.acquire()
assert ready_now is True # claimed before any tick: truly pristine
mgr.tick()
second, ready_now2 = mgr.acquire()
assert second.index != first.index
assert ready_now2 is False
assert second.phase is SlotPhase.PREFILL
def test_used_slot_requires_recycle_even_with_default_persona():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, ready_now = mgr.acquire()
assert ready_now
mgr.release(slot)
slot2, ready_now2 = mgr.acquire()
assert slot2.index == slot.index
assert ready_now2 is False
assert slot2.phase is SlotPhase.PREFILL
def test_acquire_returns_none_when_full():
eng = StubEngine(batch_size=2)
mgr = BatchedSessionManager(eng)
assert mgr.acquire() is not None
assert mgr.acquire() is not None
assert mgr.acquire() is None
def test_feed_frames_epoch_guard_and_backlog_cap():
eng = StubEngine()
mgr = BatchedSessionManager(eng, max_buffered_frames=3)
slot, _ = mgr.acquire()
mgr.feed(slot, np.arange(FRAME * 2, dtype=np.float32), slot.epoch)
assert len(slot.inq) == 2
mgr.feed(slot, np.zeros(3, dtype=np.float32), slot.epoch) # partial tail buffered
assert len(slot.inq) == 2 and slot.pending.shape[0] == 3
mgr.feed(slot, np.zeros(FRAME * 5, dtype=np.float32), slot.epoch)
assert len(slot.inq) == 3 # oldest dropped beyond the cap
mgr.feed(slot, np.zeros(FRAME, dtype=np.float32), slot.epoch - 1) # stale epoch ignored
assert len(slot.inq) == 3
def test_live_slot_consumes_input_and_emits_output():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, _ = mgr.acquire()
got = []
slot.on_output = got.append
frame = np.full(FRAME, 0.5, dtype=np.float32)
mgr.feed(slot, frame, slot.epoch)
mgr.tick()
assert len(got) == 1
np.testing.assert_array_equal(got[0].audio, frame)
# queue drained: next tick feeds zeros
mgr.tick()
np.testing.assert_array_equal(got[1].audio, np.zeros(FRAME, dtype=np.float32))
def test_release_mid_flight_stops_emission():
eng = StubEngine()
mgr = BatchedSessionManager(eng)
slot, _ = mgr.acquire()
got = []
slot.on_output = got.append
mgr.tick()
assert len(got) == 1
mgr.release(slot)
# another slot keeps the batch non-idle
other, _ = mgr.acquire() # same slot index, now PREFILL (used)
assert other.phase is SlotPhase.PREFILL
mgr.tick()
assert len(got) == 1 # no further output for the released epoch
@@ -0,0 +1,240 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""GPU e2e for PersonaPlex elastic batching (bit-exact isolation + recycle).
Greedy decoding makes every cross-slot leak observable as a bit difference:
1. Isolation (bit-exact): recycling one slot mid-flight leaves every other
slot's output bit-identical to a run that never recycled.
2. Replay exactness: a recycled slot's opening live ticks match a pristine boot
at numerical-noise level (a broken KV mask / staging roll garbles frame 0).
3. Coherence (behavioral): a recycled slot fed a real spoken question produces
a real reply in its inner-monologue text stream.
Run (moshi-free native stack; ~30 GB GPU + HF access to the gated repo; set
PPLEX_QUESTION_WAV to a spoken-question wav to enable the coherence test):
pytest tests/e2e/features/fullduplex/test_personaplex_elastic.py -v -s
Bench (B sweep, ms/tick vs the 80 ms budget):
python tests/e2e/features/fullduplex/test_personaplex_elastic.py --bench 2 4 8 16
"""
from __future__ import annotations
import numpy as np
import pytest
torch = pytest.importorskip("torch")
# slow + H100 + omni routes this module into the weekly Buildkite E2E group
# ("Omni · H100" in .buildkite/cuda/test-weekly.yml: -m "slow and H100 and
# omni" --run-level full_model); the ready/merge CPU sweeps exclude it by mark.
pytestmark = [pytest.mark.full_model, pytest.mark.omni, pytest.mark.slow, pytest.mark.H100]
if not torch.cuda.is_available(): # noqa: E402
pytest.skip("needs CUDA", allow_module_level=True)
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig # noqa: E402
from vllm_omni.experimental.fullduplex.personaplex.runtime import ( # noqa: E402
PersonaPlexEngine,
)
B = 4
LIVE_TICKS = 40 # live frames compared per claim
RECYCLE_AT = 10
def _make_engine() -> PersonaPlexEngine:
cfg = PersonaPlexConfig(batch_size=B) # native stepper is greedy-only
try:
return PersonaPlexEngine(cfg).load()
except Exception as exc: # gated-repo access is environment-dependent
name = type(exc).__name__
if "Gated" in name or "401" in str(exc) or "403" in str(exc):
pytest.skip(f"no access to the gated nvidia/personaplex-7b-v1 repo: {name}")
raise
@pytest.fixture(scope="module")
def engine() -> PersonaPlexEngine:
return _make_engine()
def _slot_stream(slot: int, tick: int, frame: int) -> np.ndarray:
"""Deterministic per-slot input: distinct low-amplitude tones."""
sr = 24000
freq = 180.0 + 90.0 * slot
t0 = tick * frame
t = (np.arange(frame) + t0) / sr
return (0.12 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _run(engine, total_ticks: int, recycle: bool):
"""Run the lockstep loop; optionally recycle slot 1 at RECYCLE_AT.
Returns per-slot lists of (audio | None) per live tick index, where the
recycled slot's list restarts its input stream at its post-prefill tick.
"""
frame = engine.frame_size
outs: list[list[np.ndarray | None]] = [[] for _ in range(B)]
stream_tick = [0] * B # per-slot conversation clock driving its input
prefill = None
tick = 0
while tick < total_ticks:
if recycle and tick == RECYCLE_AT and prefill is None:
engine.recycle_slot(1)
prefill = list(engine.prefill_steps(None))
pcm = np.zeros((B, frame), dtype=np.float32)
step_for_1 = None
if prefill:
while prefill and prefill[0].kind == "voice_end":
engine.voice_end(1)
prefill.pop(0)
if prefill:
step_for_1 = prefill.pop(0)
else:
engine.end_prefill(1)
prefill = None
stream_tick[1] = 0 # restart slot 1's input stream from its own t=0
for b in range(B):
if b == 1 and step_for_1 is not None:
continue # engine overrides the user channel for prefill rows
pcm[b] = _slot_stream(b, stream_tick[b], frame)
stream_tick[b] += 1
frame_outs = engine.step_batch(pcm, {1: step_for_1} if step_for_1 is not None else None)
for b in range(B):
if b == 1 and step_for_1 is not None:
continue # prefill tick: nothing conversational to record
frame_out = frame_outs[b]
outs[b].append(None if frame_out is None else frame_out.audio)
tick += 1
return outs
def test_recycle_isolation_and_fresh_equivalence(engine):
engine.open_batch()
prefill_len = sum(1 for s in engine.prefill_steps(None) if s.kind != "voice_end")
total = RECYCLE_AT + prefill_len + LIVE_TICKS
base = _run(engine, total, recycle=False)
engine.open_batch() # deterministic re-boot (greedy, fixed voice/persona)
rec = _run(engine, total, recycle=True)
# Claim 1 -- isolation: untouched slots bit-identical to the no-recycle run.
for b in (0, 2, 3):
assert len(base[b]) == total and len(rec[b]) == total
for i, (x, y) in enumerate(zip(base[b], rec[b])):
assert (x is None) == (y is None), f"slot {b} tick {i} validity diverged"
if x is not None:
assert np.array_equal(x, y), f"slot {b} tick {i} audio diverged (recycle leaked)"
# Claim 2 -- replay exactness: the recycled slot's opening live ticks match a
# pristine boot at numerical-noise level. A broken KV mask / staging-ring
# roll / Mimi reset garbles the audio from the very first frame (diffs of
# 0.1-1.0); a correct replay differs only by decoder-state rounding (~1e-4).
#
# Full-length bitwise identity is NOT expected, for two structural reasons:
# the replayed system prompt sits at a different ABSOLUTE RoPE offset (bf16
# rounds differently there), and the recycled Mimi ENCODER cannot restore a
# fresh stream's start padding (a batch-shared scalar), so from the first
# non-silent user frame it emits different-but-equally-valid codes for the
# same audio. Once any near-tie flips a token, the two equally valid
# trajectories legitimately diverge; coherence of the recycled slot is
# asserted behaviorally in test_recycled_slot_converses.
fresh = [a for a in base[1] if a is not None][:LIVE_TICKS]
reborn = [a for a in rec[1][RECYCLE_AT:] if a is not None][:LIVE_TICKS]
assert len(reborn) >= LIVE_TICKS // 2, "recycled slot produced too few live frames"
diffs = [float(np.max(np.abs(x - y))) for x, y in zip(fresh, reborn)]
first_flip = next((i for i, d in enumerate(diffs) if d > 5e-2), len(diffs))
print(
f"[elastic] replay exactness: {first_flip}/{len(diffs)} opening ticks at noise level "
f"(head diffs: {[f'{d:.1e}' for d in diffs[:6]]})"
)
assert first_flip >= 4, (
f"recycled slot diverged from fresh behavior at tick {first_flip} "
f"(diffs {diffs[: first_flip + 2]}); the system-prompt replay is broken"
)
assert max(diffs[:4]) < 2e-2, f"reborn audio not at noise level: {max(diffs[:4])}"
def test_recycled_slot_converses(engine):
"""Behavioral guarantee: a recycled slot holds a coherent conversation.
Feed a real spoken question into a slot that was recycled mid-flight (while
other slots keep running); the model's own inner-monologue text stream must
produce a real reply. This is the end-user property the encoder-state
caveats above cannot break.
"""
import os
import sphn
wav_path = os.environ.get("PPLEX_QUESTION_WAV")
if not wav_path:
pytest.skip("set PPLEX_QUESTION_WAV to a spoken-question wav to run")
wav, _ = sphn.read(wav_path, sample_rate=24000)
wav = wav[0].astype(np.float32)
frame = engine.frame_size
pad = (-len(wav)) % frame
wav = np.concatenate([wav, np.zeros(pad, dtype=np.float32)])
q_frames = [wav[i : i + frame] for i in range(0, len(wav), frame)]
engine.open_batch()
pcm = np.zeros((B, frame), dtype=np.float32)
for _ in range(5): # everyone live briefly
engine.step_batch(pcm)
engine.recycle_slot(1)
prefill = list(engine.prefill_steps(None))
while prefill:
while prefill and prefill[0].kind == "voice_end":
engine.voice_end(1)
prefill.pop(0)
if not prefill:
break
engine.step_batch(pcm, {1: prefill.pop(0)})
engine.end_prefill(1)
text = ""
total = len(q_frames) + 120 # question + ~10 s to answer
for i in range(total):
pcm = np.zeros((B, frame), dtype=np.float32)
if i < len(q_frames):
pcm[1] = q_frames[i]
outs = engine.step_batch(pcm)
if outs[1] is not None and outs[1].text:
text += outs[1].text
alpha = sum(c.isalpha() for c in text)
print(f"[elastic] recycled slot inner monologue: {text!r}")
assert alpha >= 15, f"recycled slot produced no coherent reply: {text!r}"
def _bench(batch_sizes: list[int]) -> None:
import time
for bs in batch_sizes:
cfg = PersonaPlexConfig(batch_size=max(bs, 2))
eng = PersonaPlexEngine(cfg).load()
eng.open_batch()
frame = eng.frame_size
pcm = np.zeros((cfg.batch_size, frame), dtype=np.float32)
for _ in range(10): # warm
eng.step_batch(pcm)
torch.cuda.synchronize()
t0 = time.monotonic()
n = 100
for _ in range(n):
eng.step_batch(pcm)
torch.cuda.synchronize()
ms = (time.monotonic() - t0) / n * 1000
mem = torch.cuda.max_memory_allocated() / 2**30
print(f"B={cfg.batch_size:3d} {ms:6.1f} ms/tick RTF/stream {ms / 80:.3f} {mem:.1f} GiB")
del eng
torch.cuda.empty_cache()
if __name__ == "__main__":
import sys
sizes = [int(x) for x in sys.argv[2:]] if len(sys.argv) > 2 and sys.argv[1] == "--bench" else [2, 4, 8]
_bench(sizes)
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Single-session lease teardown: a cancelled engine call must be drained.
Cancelling an asyncio task blocked on a worker-thread engine call returns
immediately while the thread keeps running (executor futures cannot be
interrupted). The server therefore tracks the in-flight future on the session
state and waits it out before releasing the capacity-one lease; otherwise a
fast reconnect could call ``open()`` on the shared engine while the previous
connection's final ``step()`` is still mutating it.
"""
import asyncio
import contextlib
import sys
import threading
import types
import pytest
try:
import sphn # noqa: F401
except ImportError:
# These lease tests never encode/decode Opus. Keep the optional serving
# dependency from hiding dependency-free teardown regressions.
sys.modules["sphn"] = types.ModuleType("sphn")
from vllm_omni.experimental.fullduplex.personaplex.config import PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.serving.server import DuplexServer
from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexServingSessionState
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.asyncio
async def test_cancelled_engine_call_is_drained_before_lease_release():
server = DuplexServer(PersonaPlexConfig()) # engine never loaded: stub calls only
state = PersonaPlexServingSessionState()
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_engine_op():
started.set()
release.wait(timeout=5.0)
finished.set()
return []
task = asyncio.create_task(server._engine_call(state, slow_engine_op))
await asyncio.to_thread(started.wait, 5.0)
# Cancel while the worker thread is mid-call: the await returns at once,
# the thread keeps running, and its completion event stays tracked on the
# state (the asyncio future itself reads as cancelled/done here, which is
# exactly why the drain must not rely on it).
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
assert state.inflight is not None
assert not state.inflight.is_set()
# The drain must genuinely wait for the worker, not just return.
drain = asyncio.create_task(server._drain_inflight(state))
await asyncio.sleep(0.05)
assert not drain.done()
release.set()
await asyncio.wait_for(drain, timeout=5.0)
assert finished.is_set()
assert state.inflight.is_set()
@pytest.mark.asyncio
async def test_completed_engine_call_clears_inflight():
server = DuplexServer(PersonaPlexConfig())
state = PersonaPlexServingSessionState()
result = await server._engine_call(state, lambda: "ok")
assert result == "ok"
assert state.inflight is None
await server._drain_inflight(state) # no-op on a clean state
@pytest.mark.asyncio
async def test_drain_timeout_keeps_lease_until_worker_finishes():
server = DuplexServer(PersonaPlexConfig())
server._active = True
state = PersonaPlexServingSessionState()
state.inflight = threading.Event()
drained = await server._release_lease_after_drain(state, timeout=0.01)
assert drained is False
assert server._active is True
state.inflight.set()
for _ in range(100):
if not server._active:
break
await asyncio.sleep(0.01)
assert server._active is False
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Lockstep-contract tests for the PersonaPlex session driver (no GPU / no moshi).
A stub :class:`FrameStepper` stands in for the real Moshi backend so we can assert
the framing + 1-frame-in / 1-frame-out invariants without weights.
"""
import numpy as np
import pytest
from vllm_omni.experimental.fullduplex.personaplex.config import FRAME_SIZE, PersonaPlexConfig
from vllm_omni.experimental.fullduplex.personaplex.engine import FrameOutput, FrameStepper
from vllm_omni.experimental.fullduplex.personaplex.session import PersonaPlexSession
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class StubStepper:
"""Deterministic FrameStepper: frame i -> audio filled with i, text 't{i}' on odds."""
sample_rate = 24000
frame_size = FRAME_SIZE
def __init__(self) -> None:
self.opened: tuple[str | None, str | None] | None = None
self.steps = 0
def open_session(self, voice_prompt=None, persona=None) -> None:
self.opened = (voice_prompt, persona)
self.steps = 0
def step(self, user_pcm):
assert user_pcm.shape[0] == self.frame_size
self.steps += 1
i = self.steps
return FrameOutput(
audio=np.full(self.frame_size, float(i), dtype=np.float32),
text=(f"t{i}" if i % 2 else None),
)
def test_stub_satisfies_protocol():
assert isinstance(StubStepper(), FrameStepper)
def test_feed_emits_one_output_per_complete_frame():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
s.open(voice_prompt="NATF2.pt", persona="be terse")
out = s.feed(np.zeros(FRAME_SIZE * 3, dtype=np.float32))
assert len(out) == 3
# lockstep order preserved: frame i -> audio filled with i
assert [int(o.audio[0]) for o in out] == [1, 2, 3]
assert [o.text for o in out] == ["t1", None, "t3"]
def test_partial_frames_are_buffered_until_complete():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open()
assert s.feed(np.zeros(FRAME_SIZE // 2, dtype=np.float32)) == [] # nothing yet
assert stub.steps == 0
out = s.feed(np.zeros(FRAME_SIZE, dtype=np.float32)) # 1.5 frames total -> 1 frame
assert len(out) == 1
assert stub.steps == 1
def test_flush_pads_the_tail():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open()
s.feed(np.ones(FRAME_SIZE + 10, dtype=np.float32)) # 1 frame + 10-sample tail
assert stub.steps == 1
tail = s.flush()
assert len(tail) == 1 and stub.steps == 2
assert s.flush() == [] # nothing left
def test_run_concatenates_audio_and_joins_text():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
s.open()
audio, text = s.run(np.zeros(FRAME_SIZE * 4, dtype=np.float32))
assert audio.shape[0] == FRAME_SIZE * 4
assert text == "t1t3" # frames 1..4, text on odds
def test_open_forwards_voice_and_persona():
stub = StubStepper()
s = PersonaPlexSession(stub, PersonaPlexConfig())
s.open(voice_prompt="NATM1.pt", persona="pirate")
assert stub.opened == ("NATM1.pt", "pirate")
def test_feed_before_open_raises():
s = PersonaPlexSession(StubStepper(), PersonaPlexConfig())
with pytest.raises(RuntimeError):
s.feed(np.zeros(FRAME_SIZE, dtype=np.float32))
@@ -0,0 +1,492 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Strict PersonaPlex validation for the unified OpenAI Realtime duplex path."""
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import json
import math
import uuid
import wave
from collections.abc import Sequence
from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import numpy as np
from vllm_omni.experimental.fullduplex.client import (
RealtimeDuplexClient,
wait_for,
write_pcm16_wav,
)
SAMPLE_RATE_HZ = 24_000
FRAME_SAMPLES = 1_920
FRAME_PERIOD_S = FRAME_SAMPLES / SAMPLE_RATE_HZ
def _input_identity(
path: Path,
*,
expected_sha256: str | None,
) -> dict[str, str]:
resolved = path.resolve()
digest = hashlib.sha256()
with resolved.open("rb") as input_file:
for chunk in iter(lambda: input_file.read(1024 * 1024), b""):
digest.update(chunk)
actual = digest.hexdigest()
if expected_sha256 is not None and actual != expected_sha256.lower():
raise ValueError(
f"input WAV SHA-256 mismatch: expected={expected_sha256.lower()}, actual={actual}, path={resolved}"
)
return {"path": str(resolved), "sha256": actual}
def _realtime_url(base_url: str, model: str, session_id: str) -> str:
parts = urlsplit(base_url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(duplex="1", model=model, autostart="0", session_id=session_id)
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _read_wav_as_float32(path: Path) -> np.ndarray:
with wave.open(str(path), "rb") as wav_file:
channels = wav_file.getnchannels()
sample_width = wav_file.getsampwidth()
source_rate = wav_file.getframerate()
frames = wav_file.readframes(wav_file.getnframes())
dtypes = {1: np.uint8, 2: np.dtype("<i2"), 4: np.dtype("<i4")}
if sample_width not in dtypes:
raise ValueError(f"unsupported WAV sample width: {sample_width}")
pcm = np.frombuffer(frames, dtype=dtypes[sample_width]).astype(np.float32)
if sample_width == 1:
pcm = (pcm - 128.0) / 128.0
else:
pcm /= float(1 << (sample_width * 8 - 1))
if channels > 1:
pcm = pcm.reshape(-1, channels).mean(axis=1)
if source_rate != SAMPLE_RATE_HZ and pcm.size:
output_samples = max(1, round(pcm.size * SAMPLE_RATE_HZ / source_rate))
pcm = np.interp(
np.linspace(0, pcm.size - 1, output_samples),
np.arange(pcm.size),
pcm,
).astype(np.float32)
pcm = np.ascontiguousarray(pcm, dtype="<f4")
if not pcm.size or not np.isfinite(pcm).all():
raise ValueError("input WAV must contain finite audio")
return pcm
def _events(client: RealtimeDuplexClient, event_type: str) -> list[dict[str, object]]:
return [event for event in client.events.events if event.get("type") == event_type]
async def _open_session(
args: argparse.Namespace,
*,
session_id: str,
persona: str,
expect_error: bool = False,
) -> tuple[RealtimeDuplexClient, dict[str, object]]:
client = RealtimeDuplexClient(_realtime_url(args.url, args.model, session_id))
await client.__aenter__()
await client.send(
{
"type": "session.update",
"session": {
"session_id": session_id,
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm_f32le",
"output_audio_format": "pcm16",
"voice": args.voice,
"instructions": persona,
"turn_detection": None,
"extra_body": {"auto_response": True},
},
}
)
event_type = "error" if expect_error else "session.created"
await wait_for(
lambda: client.events.count(event_type) > 0,
timeout_s=args.timeout_s,
label=event_type,
)
return client, _events(client, event_type)[-1]
async def _close_session(client: RealtimeDuplexClient, *, timeout_s: float) -> None:
await client.send({"type": "session.close"})
await wait_for(
lambda: client.events.count("session.closed") > 0,
timeout_s=timeout_s,
label="session.closed",
)
await client.__aexit__(None, None, None)
async def _stream_frames(
client: RealtimeDuplexClient,
pcm: np.ndarray,
*,
max_frames: int | None = None,
) -> int:
frame_count = math.ceil(pcm.size / FRAME_SAMPLES)
if max_frames is not None:
if max_frames < 0:
raise ValueError(f"max_frames must be non-negative, got {max_frames}")
if max_frames > 0:
frame_count = min(frame_count, max_frames)
for seq in range(frame_count):
frame = pcm[seq * FRAME_SAMPLES : (seq + 1) * FRAME_SAMPLES]
frame = np.pad(frame, (0, FRAME_SAMPLES - frame.size))
frame = np.ascontiguousarray(frame, dtype="<f4")
await client.send(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
"format": "pcm_f32le",
"sample_rate_hz": SAMPLE_RATE_HZ,
"duration_ms": 80,
"audio_end_ms": (seq + 1) * 80,
"is_speech": bool(np.sqrt(np.mean(np.square(frame))) >= 1e-4),
}
)
await asyncio.sleep(FRAME_PERIOD_S)
return frame_count
def _audio_frame_stats(
raw: bytes,
*,
input_frames: int,
voiced_frame_rms_threshold: float,
) -> dict[str, int | float]:
pcm = np.frombuffer(raw, dtype="<i2")
if pcm.size % FRAME_SAMPLES:
raise AssertionError(
"PersonaPlex output must contain whole 80 ms codec frames: "
f"samples={pcm.size}, samples_per_frame={FRAME_SAMPLES}"
)
output_frames = pcm.size // FRAME_SAMPLES
frames = pcm.astype(np.float32).reshape(output_frames, FRAME_SAMPLES) / 32768.0
voiced_frames = int(np.count_nonzero(np.sqrt(np.mean(np.square(frames), axis=1)) >= voiced_frame_rms_threshold))
return {
"output_frames": int(output_frames),
"voiced_frames": voiced_frames,
"silent_frames": int(output_frames - voiced_frames),
"frame_deficit": int(input_frames - output_frames),
"frame_coverage_ratio": float(output_frames / input_frames),
}
def _response_ids(client: RealtimeDuplexClient) -> set[str]:
return {
str(event["response_id"])
for event in _events(client, "response.audio.delta")
if isinstance(event.get("response_id"), str)
}
def _session_result(
client: RealtimeDuplexClient,
*,
input_frames: int,
args: argparse.Namespace,
minimum_chunks: int,
) -> tuple[bytes, set[str], dict[str, object]]:
chunks = sum(len(items) for items in client.events.response_audio.values())
raw = client.events.audio_bytes()
if chunks < minimum_chunks or not raw or len(raw) % 2:
raise AssertionError(f"invalid PCM16 output: chunks={chunks}, bytes={len(raw)}")
pcm = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
rms = float(np.sqrt(np.mean(np.square(pcm))))
# This low floor only catches corrupt, non-finite, or effectively zero PCM.
# Audible speech is checked per frame below with the higher 1e-3 default.
if not np.isfinite(pcm).all() or rms <= 1e-5:
raise AssertionError(f"output audio is non-finite or silent: samples={pcm.size}, rms={rms}")
rates = {
int(event["sample_rate_hz"])
for event in _events(client, "response.audio.delta")
if isinstance(event.get("sample_rate_hz"), int)
}
if rates != {SAMPLE_RATE_HZ}:
raise AssertionError(f"unexpected output sample rates: {sorted(rates)}")
stats = _audio_frame_stats(
raw,
input_frames=input_frames,
voiced_frame_rms_threshold=args.voiced_frame_rms_threshold,
)
if not 0 <= int(stats["frame_deficit"]) <= args.max_frame_deficit:
raise AssertionError(f"invalid synchronous frame accounting: input={input_frames}, stats={stats}")
min_voiced_frames = int(args.min_voiced_frames)
if min_voiced_frames < 1:
raise ValueError(f"min_voiced_frames must be positive, got {min_voiced_frames}")
if int(stats["voiced_frames"]) < min_voiced_frames:
raise AssertionError(
f"output contained insufficient audible speech: minimum_voiced_frames={min_voiced_frames}, stats={stats}"
)
runtime_metadata = [
metadata["vllm_omni"]
for event in _events(client, "response.audio.delta")
if isinstance((metadata := event.get("metadata")), dict) and isinstance(metadata.get("vllm_omni"), dict)
]
if not any(
item.get("runtime_impl") == "scheduler_data_plane"
and item.get("uses_model_runner_scheduler") is True
and item.get("runner_kv_backed") is True
for item in runtime_metadata
):
raise AssertionError(f"audio events did not prove the scheduler data plane: {runtime_metadata}")
response_ids = _response_ids(client)
if len(response_ids) != 1:
raise AssertionError(f"continuous stream used multiple responses: {sorted(response_ids)}")
return (
raw,
response_ids,
{
**stats,
"audio_chunks": chunks,
"audio_samples": len(raw) // 2,
"audio_rms": rms,
"response_ids": sorted(response_ids),
},
)
def _capabilities(created: dict[str, object]) -> dict[str, object]:
session = created.get("session")
capabilities = session.get("capabilities") if isinstance(session, dict) else None
if not isinstance(capabilities, dict):
raise AssertionError(f"session.created omitted capabilities: {created}")
return capabilities
def _save(output_dir: Path, name: str, client: RealtimeDuplexClient, raw: bytes) -> None:
(output_dir / f"{name}-events.jsonl").write_text(
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in client.events.events),
encoding="utf-8",
)
write_pcm16_wav(output_dir / f"{name}-output.wav", raw, sample_rate_hz=SAMPLE_RATE_HZ)
async def run(args: argparse.Namespace) -> dict[str, object]:
input_path = Path(args.input_wav)
input_identity = _input_identity(
input_path,
expected_sha256=args.expected_input_sha256,
)
pcm = _read_wav_as_float32(input_path)
if args.tail_s > 0:
pcm = np.concatenate([pcm, np.zeros(round(args.tail_s * SAMPLE_RATE_HZ), dtype=np.float32)])
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
ids = {name: f"personaplex-{name}-{uuid.uuid4().hex}" for name in ("primary", "secondary", "replacement")}
primary, created = await _open_session(args, session_id=ids["primary"], persona=args.persona)
secondary, secondary_created = await _open_session(
args,
session_id=ids["secondary"],
persona=args.secondary_persona,
)
capabilities = _capabilities(created)
if _capabilities(secondary_created) != capabilities:
raise AssertionError("concurrent sessions returned different capabilities")
expected_capabilities = {
"implementation_level": "model_native_duplex",
"chunk_period_ms": 80,
"supports_multi_session": True,
"supports_multi_session_same_replica": True,
"supports_barge_in": False,
}
if any(capabilities.get(key) != value for key, value in expected_capabilities.items()):
raise AssertionError(f"unexpected PersonaPlex capabilities: {capabilities}")
overflow, error = await _open_session(
args,
session_id=f"personaplex-overflow-{uuid.uuid4().hex}",
persona=args.persona,
expect_error=True,
)
error_body = error.get("error")
overflow_code = error_body.get("code") if isinstance(error_body, dict) else error.get("code")
await overflow.__aexit__(None, None, None)
if overflow_code != "resource_exhausted":
raise AssertionError(f"overflow session was not rejected: {error}")
primary_frames, secondary_frames = await asyncio.gather(
_stream_frames(primary, pcm),
_stream_frames(secondary, pcm),
)
await asyncio.gather(
wait_for(
lambda: len(primary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= primary_frames - args.max_frame_deficit,
timeout_s=args.timeout_s,
label="primary frame coverage",
),
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES) >= secondary_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="secondary frame coverage",
),
)
await asyncio.sleep(args.drain_s)
primary_audio, primary_response_ids, primary_stats = _session_result(
primary,
input_frames=primary_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks,
)
secondary_audio, secondary_response_ids, _ = _session_result(
secondary,
input_frames=secondary_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks,
)
if primary_response_ids & secondary_response_ids:
raise AssertionError("concurrent sessions shared a response id")
_save(output_dir, "primary", primary, primary_audio)
await _close_session(primary, timeout_s=args.timeout_s)
replacement, replacement_created = await _open_session(
args,
session_id=ids["replacement"],
persona=args.replacement_persona,
)
if _capabilities(replacement_created) != capabilities:
raise AssertionError("replacement session returned different capabilities")
continuation_frames, replacement_frames = await asyncio.gather(
_stream_frames(secondary, pcm, max_frames=args.replacement_frames),
_stream_frames(replacement, pcm, max_frames=args.replacement_frames),
)
secondary_total_frames = secondary_frames + continuation_frames
await asyncio.gather(
wait_for(
lambda: (
len(secondary.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= secondary_total_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="survivor frame coverage after slot recycle",
),
wait_for(
lambda: (
len(replacement.events.audio_bytes()) // (2 * FRAME_SAMPLES)
>= replacement_frames - args.max_frame_deficit
),
timeout_s=args.timeout_s,
label="replacement frame coverage",
),
)
await asyncio.sleep(args.drain_s)
secondary_audio, secondary_after_ids, secondary_stats = _session_result(
secondary,
input_frames=secondary_total_frames,
args=args,
minimum_chunks=args.minimum_audio_chunks + 1,
)
replacement_audio, replacement_response_ids, replacement_stats = _session_result(
replacement,
input_frames=replacement_frames,
args=args,
minimum_chunks=1,
)
if secondary_after_ids != secondary_response_ids:
raise AssertionError("survivor response changed while another session slot was recycled")
if replacement_response_ids & (primary_response_ids | secondary_response_ids):
raise AssertionError("replacement session reused another session's response id")
_save(output_dir, "secondary", secondary, secondary_audio)
_save(output_dir, "replacement", replacement, replacement_audio)
await asyncio.gather(
_close_session(secondary, timeout_s=args.timeout_s),
_close_session(replacement, timeout_s=args.timeout_s),
)
errors = primary.events.errors() + secondary.events.errors() + replacement.events.errors()
result = {
"ok": not errors,
"model": args.model,
"input": {**input_identity, "tail_s": args.tail_s},
"capabilities": capabilities,
"overflow_error_code": overflow_code,
"primary": {"session_id": ids["primary"], "input_frames": primary_frames, **primary_stats},
"secondary": {
"session_id": ids["secondary"],
"input_frames": secondary_total_frames,
"continuation_frames_after_recycle": continuation_frames,
**secondary_stats,
},
"replacement": {
"session_id": ids["replacement"],
"input_frames": replacement_frames,
**replacement_stats,
},
"errors": errors,
"output_dir": str(output_dir),
}
(output_dir / "result.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
if errors:
raise AssertionError(f"Realtime sessions emitted errors: {errors}")
return result
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime?duplex=1")
parser.add_argument("--model", required=True)
parser.add_argument("--input-wav", required=True)
parser.add_argument(
"--expected-input-sha256",
help="Fail before opening sessions when the input WAV does not match this SHA-256.",
)
parser.add_argument("--output-dir", default="/tmp/personaplex-realtime-duplex")
parser.add_argument("--voice", default="NATF2.pt")
parser.add_argument("--persona", default="You are a concise and helpful assistant.")
parser.add_argument("--secondary-persona", default="You are a calm and precise assistant.")
parser.add_argument("--replacement-persona", default="You are a cheerful travel assistant.")
parser.add_argument("--tail-s", type=float, default=2.0)
parser.add_argument("--drain-s", type=float, default=2.0)
parser.add_argument("--timeout-s", type=float, default=180.0)
parser.add_argument(
"--max-frame-deficit",
type=int,
default=4,
help=(
"Maximum unflushed tail frames. PersonaPlex Code2Wav emits five-frame "
"chunks, so a session cutoff can leave at most four pending frames."
),
)
parser.add_argument("--voiced-frame-rms-threshold", type=float, default=1e-3)
parser.add_argument(
"--min-voiced-frames",
type=int,
default=5,
help=(
"Require at least one normal five-frame Code2Wav chunk (400 ms) above "
"the per-frame RMS threshold; this detects inaudible output, not speech quality."
),
)
parser.add_argument("--minimum-audio-chunks", type=int, default=2)
parser.add_argument(
"--replacement-frames",
type=int,
default=0,
help="Frames sent after slot reuse; 0 replays the full input so the replacement must produce audible output.",
)
return parser.parse_args(argv)
def main() -> None:
print(json.dumps(asyncio.run(run(parse_args())), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -83,7 +83,11 @@ class FakeDeadLLMStageClient(FakeStageClient):
async def get_output_async(self):
if not self.add_request_calls:
return SimpleNamespace(outputs=[])
return SimpleNamespace(
outputs=[],
scheduler_stats=None,
finished_requests=None,
)
raise EngineDeadError("Stage-0 engine core is dead")
@@ -175,6 +175,26 @@ def test_wakeup_engine_not_support(sleep_incapable_engine):
assert "wake_up" in response.json()["detail"]
def test_wakeup_route_propagates_not_implemented_error(sleep_capable_engine, mocker):
"""``/v1/omni/wakeup`` must not swallow ``NotImplementedError`` from the engine.
Route exception-propagation only: the mock authors the exception on purpose.
The real level-2 discarded-weight path and live HTTP 501 mapping are covered by
``test_wakeup_after_level2_sleep_fails``.
"""
sleep_capable_engine.wake_up = mocker.AsyncMock(side_effect=NotImplementedError("engine wake_up refused"))
app = _make_app(sleep_capable_engine)
app.state.sleeping_stages = {0}
# Re-raise in-process; default TestClient would wrap this as a 500 response.
client = TestClient(app, raise_server_exceptions=True)
with pytest.raises(NotImplementedError, match="engine wake_up refused"):
client.post("/v1/omni/wakeup", json={"stage_ids": [0]})
# Failed wake must not clear the sleeping set.
assert app.state.sleeping_stages == {0}
def test_wakeup_removes_only_requested_stages(sleep_capable_engine):
app = _make_app(sleep_capable_engine)
app.state.sleeping_stages = {0, 1, 2}
@@ -17,7 +17,7 @@ from types import SimpleNamespace
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from PIL import Image
from pytest_mock import MockerFixture
@@ -62,10 +62,14 @@ class FakeAsyncOmni:
def __init__(self):
self.stage_configs = [SimpleNamespace(stage_type="diffusion")]
self.default_sampling_params_list = [OmniDiffusionSamplingParams()]
self.model_class_name = "WanPipeline"
self.captured_prompt = None
self.captured_reference_video_bytes = None
self.captured_sampling_params_list = None
def get_diffusion_od_config(self):
return SimpleNamespace(model_class_name=self.model_class_name)
async def generate(self, prompt, request_id, sampling_params_list):
self.captured_prompt = prompt
self.captured_sampling_params_list = sampling_params_list
@@ -487,7 +491,7 @@ def test_v2v_video_generation_form(test_client, mocker: MockerFixture):
assert input_video[0].size == (32, 24)
def test_v2v_video_generation_with_video_reference_form(test_client, mocker: MockerFixture):
def test_r9_typed_video_reference_form(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
@@ -506,8 +510,53 @@ def test_v2v_video_generation_with_video_reference_form(test_client, mocker: Moc
engine = test_client.app.state.openai_serving_video._engine_client
input_video = engine.captured_prompt["multi_modal_data"]["video"]
assert len(input_video) == 2
assert input_video[0].size == (32, 24)
assert len(input_video) == 1
assert all(isinstance(item, str) for item in input_video)
assert engine.captured_reference_video_bytes is not None
assert len(engine.captured_reference_video_bytes) == 1
assert b"ftyp" in engine.captured_reference_video_bytes[0][:32]
def test_r5_mixed_image_video_audio_references_and_fanout(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
"/v1/videos",
data={
"prompt": "Use both references.",
"image_reference": json.dumps(
[
{"image_url": _make_test_image_data_url((40, 24))},
{"image_url": _make_test_image_data_url((40, 24))},
]
),
"video_reference": json.dumps(
{"video_url": _make_test_video_data_url((32, 24), 2)},
),
"audio_reference": json.dumps(
[{"audio_url": "data:audio/mp3;base64,ZmFrZQ=="}],
),
"extra_params": json.dumps({"task": "ref2va", "frame_indices": [0, -1], "start_time_seconds": 1.5}),
"num_outputs_per_prompt": "2",
},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
multi_modal_data = engine.captured_prompt["multi_modal_data"]
assert len(multi_modal_data["image"]) == 2
assert len(multi_modal_data["video"]) == 1
assert all(isinstance(item, str) for item in multi_modal_data["video"])
assert engine.captured_reference_video_bytes is not None
assert len(engine.captured_reference_video_bytes) == 1
assert b"ftyp" in engine.captured_reference_video_bytes[0][:32]
assert isinstance(multi_modal_data["audio"], str)
assert engine.captured_sampling_params_list[0].num_outputs_per_prompt == 2
@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"])
@@ -520,6 +569,7 @@ def test_multi_video_generation_preserves_uploaded_files_until_generation(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
endpoint,
data={
@@ -527,8 +577,10 @@ def test_multi_video_generation_preserves_uploaded_files_until_generation(
"extra_params": json.dumps({"task": "ref2va", "duration": 15.0}),
},
files=[
("input_references", ("subject.png", _make_test_image_bytes((40, 24)), "image/png")),
("input_references", ("subject.mp4", b"subject-video", "video/mp4")),
("input_references", ("background.mov", b"background-video", "video/quicktime")),
("input_references", ("voice.wav", b"reference-audio", "audio/wav")),
],
)
@@ -544,10 +596,29 @@ def test_multi_video_generation_preserves_uploaded_files_until_generation(
assert engine.captured_reference_video_bytes == [b"subject-video", b"background-video"]
assert len(input_videos) == 2
assert all(not Path(path).exists() for path in input_videos)
assert isinstance(engine.captured_prompt["multi_modal_data"]["image"], Image.Image)
assert isinstance(engine.captured_prompt["multi_modal_data"]["audio"], str)
assert engine.captured_sampling_params_list[0].extra_args["task"] == "ref2va"
assert engine.captured_sampling_params_list[0].extra_args["duration"] == 15.0
def test_decode_video_bytes_can_keep_first_frames():
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
frames = _decode_video_bytes(
_make_test_video_bytes((32, 24), num_frames=6),
source="input_reference",
max_frames=2,
keep="first",
)
assert len(frames) == 2
assert frames.fps == pytest.approx(8.0)
red_means = [np.asarray(frame)[:, :, 0].mean() for frame in frames]
assert red_means[0] < red_means[1]
assert red_means[1] < 100
def test_decode_video_bytes_can_keep_last_frames():
from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes
@@ -1183,17 +1254,67 @@ def test_rejects_input_reference_and_image_reference_together(test_client):
assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower()
def test_rejects_image_reference_and_video_reference_together(test_client):
def test_r10_typed_image_and_video_reference_form(test_client, mocker: MockerFixture):
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
"/v1/videos",
data={
"prompt": "bad refs",
"image_reference": '{"image_url": "https://example.com/cat.png"}',
"video_reference": '{"video_url": "https://example.com/cat.mp4"}',
"prompt": "use both references",
"image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}),
"video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}),
},
)
assert response.status_code == 200
_wait_for_status(test_client, response.json()["id"], VideoGenerationStatus.COMPLETED.value)
def test_generic_video_model_rejects_mixed_image_and_video_references(test_client):
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "use both references",
"image_reference": json.dumps({"image_url": _make_test_image_data_url((40, 24))}),
"video_reference": json.dumps({"video_url": _make_test_video_data_url((32, 24), 2)}),
},
)
assert response.status_code == 400
assert "only one of input_reference, image_reference, or video_reference" in response.json()["detail"].lower()
assert "does not support mixed image and video" in response.json()["detail"].lower()
def test_h3_multipart_rejects_bmp_image_reference(test_client):
image = Image.new("RGB", (64, 64), color="blue")
image_buffer = io.BytesIO()
image.save(image_buffer, format="BMP")
test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline"
response = test_client.post(
"/v1/videos/sync",
data={"prompt": "reject unsupported image container", "extra_params": '{"task":"ref2va"}'},
files=[("input_references", ("reference.bmp", image_buffer.getvalue(), "image/bmp"))],
)
assert response.status_code == 400
assert "must use jpg" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_h3_upload_limit_checks_declared_size_before_read():
class OversizedUpload:
size = api_server.MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES + 1
async def read(self, _size):
raise AssertionError("the oversized upload must be rejected before reading")
with pytest.raises(HTTPException, match="size limit"):
await api_server._read_upload_limited(
OversizedUpload(),
max_bytes=api_server.MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES,
)
def test_invalid_seconds_returns_422(test_client):
@@ -262,6 +262,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()
+160 -211
View File
@@ -1,13 +1,19 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Omni sleep mode: entrypoint-level VRAM/ACK tests (L4) plus H100 multi-TP e2e."""
"""Omni sleep mode: entrypoint-level VRAM/ACK tests plus multi-stage e2e.
Class-scoped engines amortize BAGEL load across sleep/wake assertions within
each suite (LLM vs diffusion). Suites are separate classes so only one heavy
engine is alive at a time. Device cleanup runs once per module.
"""
import asyncio
import logging
import os
import pytest
import pytest_asyncio
import torch
from vllm import SamplingParams
@@ -20,12 +26,11 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("OmniTest")
pytestmark = [
pytest.mark.advanced_model,
pytest.mark.usefixtures("clean_gpu_memory_between_tests"),
]
def clean_gpu_envs():
"""clean up GPU environment variables to ensure tests run on all available devices."""
def clean_device_envs():
"""Clear device-visibility env vars so tests see all available devices."""
device_visibility_vars = [
"CUDA_VISIBLE_DEVICES", # NVIDIA
"HIP_VISIBLE_DEVICES", # AMD ROCm
@@ -103,11 +108,15 @@ def get_dynamic_devices(stage_idx: int, num_stages: int, tp_size: int) -> str:
return ",".join(device_ids)
@pytest.fixture(scope="function")
async def llm_engine():
if current_omni_platform.is_rocm():
clean_gpu_envs()
model_name = "ByteDance-Seed/BAGEL-7B-MoT"
async def _ensure_awake(engine: AsyncOmni, stage_ids: list[int]) -> None:
"""Best-effort full wake so the next shared-engine test starts active."""
try:
await engine.wake_up(stage_ids=stage_ids)
except Exception as e:
logger.warning("ensure_awake failed (stage_ids=%s): %s", stage_ids, e)
def _build_llm_stages() -> tuple[list[dict], list[dict]]:
common_args = {
"worker_type": "ar",
"enable_sleep_mode": True,
@@ -133,18 +142,38 @@ async def llm_engine():
},
]
connectors = [{"src_stage_id": 0, "dst_stage_id": 1, "connector_type": "queue"}]
engine = AsyncOmni(model=model_name, stages=stages, connectors=connectors, init_timeout=600, enable_sleep_mode=True)
return stages, connectors
@pytest.fixture(scope="module", autouse=True)
def _module_device_cleanup():
"""One device cleanup pass around the module (engines are class-scoped)."""
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
print("\n=== PRE-MODULE DEVICE CLEANUP (sleep_mode) ===")
run_pre_test_cleanup()
yield
print("\n=== POST-MODULE DEVICE CLEANUP (sleep_mode) ===")
run_post_test_cleanup()
@pytest_asyncio.fixture(scope="class", loop_scope="class")
async def llm_engine():
"""Shared 2-stage BAGEL LLM engine for sleep/wake protocol tests."""
if current_omni_platform.is_rocm():
clean_device_envs()
stages, connectors = _build_llm_stages()
engine = AsyncOmni(model=MODEL, stages=stages, connectors=connectors, init_timeout=600, enable_sleep_mode=True)
yield engine
engine.shutdown()
# Subprocess / driver can lag releasing VRAM; brief pause before the next test spins up new workers.
await asyncio.sleep(1.5)
@pytest.fixture(scope="function")
@pytest_asyncio.fixture(scope="class", loop_scope="class")
async def diffusion_engine():
"""Shared BAGEL diffusion TP=2 engine for sleep/wake + generate checks."""
if current_omni_platform.is_rocm():
clean_gpu_envs()
model_name = "ByteDance-Seed/BAGEL-7B-MoT"
clean_device_envs()
stages = [
{
"stage_id": 0,
@@ -165,28 +194,27 @@ async def diffusion_engine():
"final_output_type": "image",
}
]
engine = AsyncOmni(model=model_name, stages=stages, init_timeout=600, enable_sleep_mode=True)
engine = AsyncOmni(model=MODEL, stages=stages, init_timeout=600, enable_sleep_mode=True)
yield engine
engine.shutdown()
await asyncio.sleep(1.5)
class TestOmniSleepMode:
@pytest.mark.asyncio
class TestOmniLlmSleepMode:
"""LLM sleep/wake protocol tests sharing one class-scoped engine."""
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
async def test_llm_sleep_ack(self, llm_engine: AsyncOmni):
"""LLM Thinker (GPU0) Signal and Physical Recycling Audit"""
device_id = 0
try:
device_id = 0
used_before = get_device_global_memory_used_gib(device_id)
acks = await llm_engine.sleep(stage_ids=[0], level=1)
await asyncio.sleep(1.5)
used_after = get_device_global_memory_used_gib(device_id)
drop_gib = used_before - used_after
# Verification signal successful
assert all(get_ack_info(ack, "status") == "SUCCESS" for ack in acks)
# Worker-reported delta (can be 0 if get_current_memory_usage does not move) or
# GPU-global drop from mem_get_info (sees child worker processes).
total_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks)
freed_gib = total_freed_bytes / 1024**3
logger.info(
@@ -201,9 +229,42 @@ class TestOmniSleepMode:
f"ACK={freed_gib:.2f} GiB, global_drop={drop_gib:.2f} GiB"
)
finally:
llm_engine.shutdown()
await _ensure_awake(llm_engine, [0])
@pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro B: generate() must be rejected if kv_cache
is still asleep after wake_up(tags=["weights"]), instead of crashing with
CUDA illegal memory access."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
await llm_engine.wake_up(stage_ids=[0], tags=["weights"])
with pytest.raises(RuntimeError, match="partially or fully asleep"):
async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)):
pass
finally:
await _ensure_awake(llm_engine, [0])
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a
safe no-op instead of raising a cumem CUDA invalid argument error."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
first_acks = await llm_engine.wake_up(stage_ids=[0])
assert len(first_acks) > 0, "First wake_up() should return ACKs"
second_acks = await llm_engine.wake_up(stage_ids=[0])
assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}"
finally:
await _ensure_awake(llm_engine, [0])
class TestOmniDiffusionSleepMode:
"""Diffusion sleep/wake tests sharing one class-scoped TP=2 engine."""
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_diffusion_sleep_handshake(self, diffusion_engine: AsyncOmni):
"""Diffusion Worker stage signal loop"""
@@ -217,19 +278,15 @@ class TestOmniSleepMode:
assert len(acks) >= 1, "Expected at least 1 ACK from Diffusion Workers"
assert all(_get_status(ack) == "SUCCESS" for ack in acks)
logger.info(f"Success: Received {len(acks)} Diffusion Worker ACKs")
logger.info("Testing auto-wakeup before test end...")
await diffusion_engine.wake_up(stage_ids=[0])
logger.info("Test logic finished, triggering manual shutdown...")
finally:
diffusion_engine.shutdown()
logger.info("Manual shutdown executed. Test should exit now.")
await _ensure_awake(diffusion_engine, [0])
@pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_cross_device_cleanup(self, diffusion_engine: AsyncOmni):
"""Physical recycling audit: leveraging deterministic data returned by Workers"""
try:
# TP2 uses GPUs 0 and 1; measure whole-GPU usage (includes worker subprocesses).
used_before = get_device_global_memory_used_gib(0) + get_device_global_memory_used_gib(1)
acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
await asyncio.sleep(1.5)
@@ -246,52 +303,90 @@ class TestOmniSleepMode:
)
logger.info("SUCCESS: 100% weights offloaded.")
finally:
diffusion_engine.shutdown()
await _ensure_awake(diffusion_engine, [0])
@pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_diffusion_integrity_bit_level(self, diffusion_engine: AsyncOmni):
"""Bit-level consistency after Diffusion wake-up (prevent image corruption)"""
async def test_diffusion_sleep_wake_generate(self, diffusion_engine: AsyncOmni):
"""Merged diffusion lifecycle with test points from the former trio:
- ``test_diffusion_integrity_bit_level``: 4-step/512 baseline generate →
sleep → wake → post generate (images present, count unchanged).
- ``test_diffusion_vram_lifecycle_audit``: ACK/process VRAM reclaim on
sleep; after wake, process VRAM restores to within 3 GiB of initial;
post-wake generate smoke.
- ``test_diffusion_model_sleep_tp``: sleep ACK SUCCESS → wake → verify
generate (same Active→Sleep→Active→Generate path; runs on the shared
class TP=2 BagelPipeline engine rather than a second default-config load).
"""
import gc
device_id = 1
try:
# --- integrity: baseline generate at original resolution/steps ---
prompt = "A huge swimming pool, with many people swimming."
sp = OmniDiffusionSamplingParams(num_inference_steps=4, height=512, width=512, seed=42)
llm_sp = SamplingParams()
# Baseline Generation
logger.info("Running Baseline Generation...")
base_output = None
async for output in diffusion_engine.generate(prompt, request_id="base", sampling_params_list=[llm_sp, sp]):
base_output = output
assert base_output is not None and len(base_output.images) > 0
logger.info("Baseline Generation successful.")
# Sleep Level 1
# Snapshot Active VRAM after generate (lifecycle compared Sleep/Wake
# against the pre-sleep active watermark).
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_initial = get_vram_info(device_id)["reserved"]
logger.info(f"Diffusion Active VRAM before sleep: {vram_initial:.2f} GiB")
# --- lifecycle + model_sleep_tp: level-1 sleep reclaim ---
logger.info("Entering Deep Sleep (VRAM Scavenging)...")
await diffusion_engine.sleep(stage_ids=[0], level=1)
# Wake-up
acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
statuses = [get_ack_info(ack, "status") for ack in acks]
assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}"
reported_freed_bytes = sum(get_ack_info(ack, "freed_bytes", 0) for ack in acks)
reported_freed_gib = reported_freed_bytes / 1024**3
logger.info(f"Worker internally reported freed: {reported_freed_gib:.2f} GiB")
await asyncio.sleep(2)
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_sleeping = get_vram_info(device_id)["reserved"]
logger.info(f"External VRAM measurement during Sleep: {vram_sleeping:.2f} GiB")
assert reported_freed_gib > 14.0 or vram_sleeping < 5.0, (
f"Reclamation failed. Reported: {reported_freed_gib:.2f}G, Measured: {vram_sleeping:.2f}G"
)
# --- wake + VRAM restore to initial (±3 GiB) ---
logger.info("Waking up (Reloading Weights)...")
await diffusion_engine.wake_up(stage_ids=[0])
await asyncio.sleep(2.0)
import gc
gc.collect()
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_restored = get_vram_info(device_id)["reserved"]
logger.info(f"VRAM after Wake-up: {vram_restored:.2f} GiB")
assert abs(vram_restored - vram_initial) < 3.0, "VRAM failed to restore to initial levels"
# --- integrity + lifecycle: post-wake generate ---
logger.info("Running Post-Wakeup Generation...")
post_output = None
async for output in diffusion_engine.generate(prompt, request_id="post", sampling_params_list=[llm_sp, sp]):
post_output = output
# Assert result consistency
assert post_output is not None
assert len(base_output.images) == len(post_output.images)
assert post_output.images[0] is not None
logger.info("SUCCESS: Diffusion integrity verified after Sleep/Wake cycle.")
except Exception as e:
logger.error(f"Integrity test failed: {e}")
raise e
logger.info("SUCCESS: Diffusion integrity + VRAM lifecycle + sleep/wake generate verified.")
finally:
logger.info("Triggering mandatory cleanup...")
diffusion_engine.shutdown()
logger.info("Cleanup complete, test exiting.")
await _ensure_awake(diffusion_engine, [0])
class TestOmniCoordinatedSleepMode:
"""Dual-engine coordination (kept skipped; do not delete)."""
@pytest.mark.skip(
reason=(
@@ -301,7 +396,7 @@ class TestOmniSleepMode:
"Note: this test is fixture-free so skip does not init llm/diffusion engines."
)
)
@pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope="class")
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_coordinated_cross_device(self, llm_engine: AsyncOmni, diffusion_engine: AsyncOmni):
"""Heterogeneous Coordinated Cleanup Test (Talker and Diffusion on GPU 1)"""
@@ -341,172 +436,22 @@ class TestOmniSleepMode:
diffusion_engine.shutdown()
logger.info("All engines scavenged. Ready for next test.")
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_diffusion_vram_lifecycle_audit(self, diffusion_engine: AsyncOmni):
"""Diffusion memory loop: Active -> Deep Sleep -> Active -> inference sanity check"""
device_id = 1
try:
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_initial = get_vram_info(device_id)["reserved"]
logger.info(f"Diffusion Initial VRAM: {vram_initial:.2f} GiB")
# Sleep
logger.info("Triggering Level 1 Deep Sleep (Partial Weight Offloading)...")
acks = await diffusion_engine.sleep(stage_ids=[0], level=1)
reported_freed_bytes = sum(getattr(ack, "freed_bytes", 0) for ack in acks)
reported_freed_gib = reported_freed_bytes / 1024**3
logger.info(f"Worker internally reported freed: {reported_freed_gib:.2f} GiB")
await asyncio.sleep(2)
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_sleeping = get_vram_info(device_id)["reserved"]
logger.info(f"External VRAM measurement during Sleep: {vram_sleeping:.2f} GiB")
assert reported_freed_gib > 14.0 or vram_sleeping < 5.0, (
f"Reclamation failed. Reported: {reported_freed_gib:.2f}G, Measured: {vram_sleeping:.2f}G"
)
# wake-up
logger.info("Triggering Wake-up (Reloading weights to GPU)...")
await diffusion_engine.wake_up(stage_ids=[0])
await asyncio.sleep(2)
get_vram_info(device_id)
torch.accelerator.empty_cache()
vram_restored = get_vram_info(device_id)["reserved"]
logger.info(f"VRAM after Wake-up: {vram_restored:.2f} GiB")
assert abs(vram_restored - vram_initial) < 3.0, "VRAM failed to restore to initial levels"
# inference sanity check
logger.info("Running post-lifecycle inference smoke test...")
prompt = "A futuristic lab with glowing lights, high quality."
sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=512, width=512, seed=42)
llm_sp = SamplingParams()
base_img_found = False
async for output in diffusion_engine.generate(
prompt, request_id="lifecycle-check", sampling_params_list=[llm_sp, sp]
):
if output.images and output.images[0] is not None:
base_img_found = True
assert base_img_found, "Inference failed after Wake-up cycle!"
logger.info("SUCCESS: Full Diffusion Lifecycle (Active -> Sleep -> Active -> Generate) audited.")
except Exception as e:
logger.error(f"Lifecycle audit failed: {e}")
raise e
finally:
logger.info("Cleaning up engine and scavenging processes...")
diffusion_engine.shutdown()
await asyncio.sleep(1)
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_level2_sleep_wake_raises(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro A: wake_up() after sleep(level=2) must raise
NotImplementedError instead of silently producing corrupted output."""
try:
await llm_engine.sleep(stage_ids=[0], level=2)
with pytest.raises(NotImplementedError, match="sleep\\(level=2\\)"):
await llm_engine.wake_up(stage_ids=[0])
finally:
llm_engine.shutdown()
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_partial_wake_blocks_generate(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro B: generate() must be rejected if kv_cache
is still asleep after wake_up(tags=["weights"]), instead of crashing with
CUDA illegal memory access."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
await llm_engine.wake_up(stage_ids=[0], tags=["weights"])
with pytest.raises(RuntimeError, match="partially or fully asleep"):
async for _ in llm_engine.generate("test", sampling_params=SamplingParams(max_tokens=4)):
pass
finally:
llm_engine.shutdown()
@pytest.mark.asyncio
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
async def test_duplicate_wake_is_idempotent(self, llm_engine: AsyncOmni):
"""Regression for #4473 Repro C: duplicate wake_up(tags=None) must be a
safe no-op instead of raising a cumem CUDA invalid argument error."""
try:
await llm_engine.sleep(stage_ids=[0], level=1)
first_acks = await llm_engine.wake_up(stage_ids=[0])
assert len(first_acks) > 0, "First wake_up() should return ACKs"
second_acks = await llm_engine.wake_up(stage_ids=[0])
assert second_acks == [], f"Duplicate wake_up() should return [] but got {second_acks}"
finally:
llm_engine.shutdown()
# ---------------------------------------------------------------------------
# H100 (or MI325) e2e: BAGEL / pure diffusion, multi-TP
# Multi-stage (H100) / pure diffusion TP=1 (2×L4): sleep→wake→generate
# (BAGEL TP=2 diffusion path covered by TestOmniDiffusionSleepMode)
# ---------------------------------------------------------------------------
@pytest.mark.omni
@pytest.mark.advanced_model
@pytest.mark.parametrize("tp_size", [1])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=1)
@pytest.mark.asyncio
async def test_diffusion_model_sleep_tp(tp_size: int):
"""Two-stage BAGEL default config: warmup, sleep all, wake, verify generate."""
if current_omni_platform.is_rocm():
clean_gpu_envs()
num_gpus = torch.accelerator.device_count()
if num_gpus < tp_size:
pytest.skip(f"Skipping TP={tp_size}")
engine_args = {
"model": MODEL,
"enable_sleep_mode": True,
"tensor_parallel_size": tp_size,
"enforce_eager": True,
"trust_remote_code": True,
"dtype": "bfloat16",
"gpu_memory_utilization": 0.5,
}
engine = AsyncOmni(**engine_args, stage_init_timeout=1200)
try:
diff_sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256)
llm_sp = SamplingParams()
async for _ in engine.generate("test", sampling_params_list=[llm_sp, diff_sp]):
pass
acks = await engine.sleep(level=1)
statuses = [get_ack_info(ack, "status") for ack in acks]
assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}"
await engine.wake_up()
async for _ in engine.generate("verify", sampling_params_list=[llm_sp, diff_sp]):
pass
logger.info("Diffusion TP=%s lifecycle OK", tp_size)
finally:
engine.shutdown()
@pytest.mark.omni
@pytest.mark.advanced_model
@pytest.mark.parametrize("tp_size", [1, 2])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.asyncio
async def test_multistage_sleep_h100(tp_size: int):
async def test_multistage_llm_diffusion_sleep_wake(tp_size: int):
"""Explicit 2-stage (llm + diffusion) + connectors; sleep/wake both stages."""
if current_omni_platform.is_rocm():
clean_gpu_envs()
clean_device_envs()
num_gpus = torch.accelerator.device_count()
if num_gpus < tp_size * 2:
pytest.skip("Not enough GPUs")
@@ -552,18 +497,22 @@ async def test_multistage_sleep_h100(tp_size: int):
@pytest.mark.omni
@pytest.mark.advanced_model
@pytest.mark.parametrize("tp_size", [1, 2])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.core_model
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
@pytest.mark.asyncio
async def test_pure_diffusion_scenario(tp_size: int):
"""Single-stage random diffusion: sleep, wake, generate."""
async def test_pure_diffusion_sleep_wake():
"""Single-stage random diffusion (TP=1): sleep → wake → generate.
Diffusion TP=2 sleep/wake is covered by TestOmniDiffusionSleepMode
(shared BAGEL TP=2 engine); this case keeps the default AsyncOmni +
qwen_image_random entrypoint path on L4.
"""
if current_omni_platform.is_rocm():
clean_gpu_envs()
clean_device_envs()
engine_args = {
"model": MODEL_DIFF,
"enable_sleep_mode": True,
"tensor_parallel_size": tp_size,
"tensor_parallel_size": 1,
"enforce_eager": True,
"dtype": "bfloat16",
"gpu_memory_utilization": 0.5,
@@ -578,6 +527,6 @@ async def test_pure_diffusion_scenario(tp_size: int):
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256),
):
pass
logger.info("Pure diffusion OK (TP=%s)", tp_size)
logger.info("Pure diffusion sleep/wake OK")
finally:
engine.shutdown()
@@ -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)
@@ -1,120 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Regression tests for MOSS-TTS-Realtime fused code-predictor loading.
The MOSS-TTS-Realtime depth transformer shares ``CodePredictorBaseModel``, whose
q/k/v and gate/up projections are fused. Before the fix, the talker loaded those
shards with a per-tensor ``default_weight_loader``, which silently dropped every
q/k/v/gate/up shard and left the fused params randomly initialized -- no
exception, just a corrupted model.
These tests assert the property that failure mode violates: after
``load_weights``, every fused parameter is both reported loaded and numerically
equal to the concatenation of the shards that fed it.
"""
import pytest
import torch
from vllm_omni.model_executor.models.moss_tts.configuration_moss_tts import (
MossTTSLocalTransformerConfig,
)
from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_local import (
MossTTSRealtimeLocalTransformer,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _tiny_cfg():
return MossTTSLocalTransformerConfig(
head_dim=8,
hidden_size=32,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=2,
intermediate_size=64,
max_position_embeddings=8,
)
def _hf_shards(cfg, *, drop: set[str] | None = None):
"""Synthetic HF-style checkpoint tensors for the depth transformer."""
drop = drop or set()
h, inter = cfg.hidden_size, cfg.intermediate_size
q_out = cfg.num_attention_heads * cfg.head_dim
kv_out = cfg.num_key_value_heads * cfg.head_dim
out = {}
for layer in range(cfg.num_hidden_layers):
p = f"model.layers.{layer}"
for nm, rows in (("q_proj", q_out), ("k_proj", kv_out), ("v_proj", kv_out)):
key = f"{p}.self_attn.{nm}.weight"
if nm not in drop:
out[key] = torch.randn(rows, h)
out[f"{p}.self_attn.o_proj.weight"] = torch.randn(h, q_out)
for nm in ("gate_proj", "up_proj"):
key = f"{p}.mlp.{nm}.weight"
if nm not in drop:
out[key] = torch.randn(inter, h)
out[f"{p}.mlp.down_proj.weight"] = torch.randn(h, inter)
out[f"{p}.input_layernorm.weight"] = torch.randn(h)
out[f"{p}.post_attention_layernorm.weight"] = torch.randn(h)
for nm in ("q_norm", "k_norm"):
out[f"{p}.self_attn.{nm}.weight"] = torch.randn(cfg.head_dim)
out["model.norm.weight"] = torch.randn(h)
return out
def test_every_fused_param_is_loaded_and_correctly_packed():
cfg = _tiny_cfg()
lt = MossTTSRealtimeLocalTransformer(cfg)
shards = _hf_shards(cfg)
loaded = lt.load_weights(iter([(k, v) for k, v in shards.items()]))
params = dict(lt.named_parameters())
fused = [n for n in params if ".self_attn.qkv_proj." in n or ".mlp.gate_up_proj." in n]
assert fused, "model declares no fused params -- test would be vacuous"
# 1. Reported as loaded (this is what the old path silently failed).
unreported = [n for n in fused if n not in loaded]
assert not unreported, f"fused params not reported loaded: {unreported}"
# 2. Numerically equal to the concatenation of their shards, in order.
for layer in range(cfg.num_hidden_layers):
p = f"model.layers.{layer}"
want_qkv = torch.cat([shards[f"{p}.self_attn.{n}.weight"] for n in ("q_proj", "k_proj", "v_proj")], dim=0)
got_qkv = params[f"{p}.self_attn.qkv_proj.weight"].detach()
assert torch.equal(got_qkv, want_qkv), f"layer {layer}: qkv packing mismatch"
want_gu = torch.cat([shards[f"{p}.mlp.{n}.weight"] for n in ("gate_proj", "up_proj")], dim=0)
got_gu = params[f"{p}.mlp.gate_up_proj.weight"].detach()
assert torch.equal(got_gu, want_gu), f"layer {layer}: gate_up packing mismatch"
def test_incomplete_shard_group_raises_instead_of_corrupting():
"""A checkpoint missing v_proj must fail loudly, not load a random qkv."""
cfg = _tiny_cfg()
lt = MossTTSRealtimeLocalTransformer(cfg)
shards = _hf_shards(cfg, drop={"v_proj"})
with pytest.raises(RuntimeError, match="incomplete fused shards"):
lt.load_weights(iter([(k, v) for k, v in shards.items()]))
def test_absent_fused_group_raises():
"""No q/k/v at all must also raise, via the missing-fused-param guard."""
cfg = _tiny_cfg()
lt = MossTTSRealtimeLocalTransformer(cfg)
shards = _hf_shards(cfg, drop={"q_proj", "k_proj", "v_proj"})
with pytest.raises(RuntimeError, match="missing fused parameters"):
lt.load_weights(iter([(k, v) for k, v in shards.items()]))
def test_returned_names_keep_the_model_prefix():
"""Returned names must be ``model.*`` for talker re-prefixing."""
cfg = _tiny_cfg()
lt = MossTTSRealtimeLocalTransformer(cfg)
loaded = lt.load_weights(iter([(k, v) for k, v in _hf_shards(cfg).items()]))
assert loaded, "nothing reported loaded"
assert all(n.startswith("model.") for n in loaded), sorted(n for n in loaded if not n.startswith("model."))
@@ -674,291 +674,3 @@ class TestGumbelMaxSampling:
empirical = torch.bincount(samples, minlength=vocab).float() / n_draws
# L1 distance under 0.03 with 20k draws is comfortable for vocab=16.
assert (empirical - target).abs().sum().item() < 0.05
class TestCodePredictorFusedProjections:
"""Reference tests for the fused qkv_proj / gate_up_proj paths.
These CPU tests confirm that the packed projections implement the same
linear algebra as the unfused reference under this setup. GPU executions
may still diverge at the bit level when kernel choices change.
"""
def test_fused_qkv_matches_separate(self, loaded_target_classes) -> None:
torch.manual_seed(0)
hidden = 32
num_heads, num_kv_heads, head_dim = 4, 2, 8
q_size = num_heads * head_dim
kv_size = num_kv_heads * head_dim
x = torch.randn(2, 5, hidden)
q = torch.nn.Linear(hidden, q_size, bias=False)
k = torch.nn.Linear(hidden, kv_size, bias=False)
v = torch.nn.Linear(hidden, kv_size, bias=False)
fused = torch.nn.Linear(hidden, q_size + 2 * kv_size, bias=False)
with torch.no_grad():
fused.weight.copy_(torch.cat([q.weight, k.weight, v.weight], dim=0))
ref_q, ref_k, ref_v = q(x), k(x), v(x)
out_q, out_k, out_v = fused(x).split([q_size, kv_size, kv_size], dim=-1)
assert torch.equal(ref_q, out_q)
assert torch.equal(ref_k, out_k)
assert torch.equal(ref_v, out_v)
def test_attention_split_redensifies_kv_slices(self, loaded_target_classes) -> None:
cp_config, _ = _make_tiny_config(loaded_target_classes)
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
attn = common_mod.CodePredictorAttention(cp_config)
qkv = torch.randn(2, 5, attn._q_size + 2 * attn._kv_size)
q_ref, k_ref, v_ref = qkv.split([attn._q_size, attn._kv_size, attn._kv_size], dim=-1)
q_raw, k_raw, v_raw = attn._split_qkv(qkv)
assert torch.equal(q_raw, q_ref)
assert torch.equal(k_raw, k_ref)
assert torch.equal(v_raw, v_ref)
assert k_raw.is_contiguous()
assert v_raw.is_contiguous()
def test_fused_gate_up_matches_separate(self, loaded_target_classes) -> None:
torch.manual_seed(1)
hidden, intermediate = 32, 64
x = torch.randn(2, 5, hidden)
g = torch.nn.Linear(hidden, intermediate, bias=False)
u = torch.nn.Linear(hidden, intermediate, bias=False)
fused = torch.nn.Linear(hidden, 2 * intermediate, bias=False)
with torch.no_grad():
fused.weight.copy_(torch.cat([g.weight, u.weight], dim=0))
ref_g, ref_u = g(x), u(x)
out_g, out_u = fused(x).split([intermediate, intermediate], dim=-1)
assert torch.equal(ref_g, out_g)
assert torch.equal(ref_u, out_u)
def test_load_weights_repacks_hf_shards(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""``load_weights`` must transparently re-pack HF q/k/v and gate/up
shards into the fused ``qkv_proj`` and ``gate_up_proj`` weights."""
_, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
model = code_predictor_model(cp_config, embedding_dim=cp_config.hidden_size)
# The test harness mocks ``default_weight_loader`` as a no-op; install a
# real copier so this test verifies both the returned names and the
# packed parameter values/order.
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(
common_mod,
"default_weight_loader",
lambda param, weight: param.data.copy_(weight),
)
# Build a synthetic "HF checkpoint" that exposes separate q/k/v
# and gate/up shards, mirroring what HuggingFace ships.
param_dict = dict(model.named_parameters(remove_duplicate=False))
weights: list[tuple[str, torch.Tensor]] = []
expected_fused: dict[str, torch.Tensor] = {}
torch.manual_seed(42)
for name, param in param_dict.items():
if name.endswith(".self_attn.qkv_proj.weight"):
prefix = name[: -len(".qkv_proj.weight")]
num_heads = cp_config.num_attention_heads
num_kv_heads = cp_config.num_key_value_heads
head_dim = cp_config.head_dim
q_size = num_heads * head_dim
kv_size = num_kv_heads * head_dim
hidden = cp_config.hidden_size
q_w = torch.randn(q_size, hidden)
k_w = torch.randn(kv_size, hidden)
v_w = torch.randn(kv_size, hidden)
weights.append((f"{prefix}.q_proj.weight", q_w))
weights.append((f"{prefix}.k_proj.weight", k_w))
weights.append((f"{prefix}.v_proj.weight", v_w))
expected_fused[name] = torch.cat([q_w, k_w, v_w], dim=0)
elif name.endswith(".mlp.gate_up_proj.weight"):
prefix = name[: -len(".gate_up_proj.weight")]
inter = cp_config.intermediate_size
hidden = cp_config.hidden_size
gate_w = torch.randn(inter, hidden)
up_w = torch.randn(inter, hidden)
weights.append((f"{prefix}.gate_proj.weight", gate_w))
weights.append((f"{prefix}.up_proj.weight", up_w))
expected_fused[name] = torch.cat([gate_w, up_w], dim=0)
else:
# Pass-through parameters (norms, embeddings, o_proj, down_proj).
weights.append((name, torch.randn_like(param)))
loaded = model.load_weights(weights)
after = dict(model.named_parameters(remove_duplicate=False))
assert expected_fused, "test config must contain at least one fused projection layer"
for fname, expected in expected_fused.items():
assert fname in loaded, f"{fname} was not assembled by load_weights"
assert torch.equal(after[fname], expected), f"{fname} does not match the packed shard order"
def test_load_weights_repacks_bias_shards(self, mocker: MockerFixture, loaded_target_classes) -> None:
"""When ``attention_bias=True``, the q/k/v ``.bias`` shards must be
concatenated into ``qkv_proj.bias`` instead of being dropped (which
would leave the fused bias randomly initialized)."""
_, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
cp_config.attention_bias = True
model = code_predictor_model(cp_config, embedding_dim=cp_config.hidden_size)
# The test harness mocks ``default_weight_loader`` as a no-op; install a
# real copier so we can assert the packed values land on the parameter.
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(
common_mod,
"default_weight_loader",
lambda param, weight: param.data.copy_(weight),
)
param_dict = dict(model.named_parameters(remove_duplicate=False))
weights: list[tuple[str, torch.Tensor]] = []
expected_bias: dict[str, torch.Tensor] = {}
torch.manual_seed(11)
for name, param in param_dict.items():
if name.endswith(".self_attn.qkv_proj.weight"):
prefix = name[: -len(".qkv_proj.weight")]
q_size = cp_config.num_attention_heads * cp_config.head_dim
kv_size = cp_config.num_key_value_heads * cp_config.head_dim
hidden = cp_config.hidden_size
q_w, k_w, v_w = (torch.randn(s, hidden) for s in (q_size, kv_size, kv_size))
q_b, k_b, v_b = (torch.randn(s) for s in (q_size, kv_size, kv_size))
weights.append((f"{prefix}.q_proj.weight", q_w))
weights.append((f"{prefix}.k_proj.weight", k_w))
weights.append((f"{prefix}.v_proj.weight", v_w))
weights.append((f"{prefix}.q_proj.bias", q_b))
weights.append((f"{prefix}.k_proj.bias", k_b))
weights.append((f"{prefix}.v_proj.bias", v_b))
expected_bias[f"{prefix}.qkv_proj.bias"] = torch.cat([q_b, k_b, v_b], dim=0)
elif name.endswith(".mlp.gate_up_proj.weight"):
prefix = name[: -len(".gate_up_proj.weight")]
inter, hidden = cp_config.intermediate_size, cp_config.hidden_size
weights.append((f"{prefix}.gate_proj.weight", torch.randn(inter, hidden)))
weights.append((f"{prefix}.up_proj.weight", torch.randn(inter, hidden)))
elif name.endswith(".qkv_proj.bias"):
# Fused target is assembled from the q/k/v bias shards above; a
# real HF checkpoint never ships a qkv_proj.bias tensor directly.
continue
else:
weights.append((name, torch.randn_like(param)))
loaded = model.load_weights(weights)
after = dict(model.named_parameters())
assert expected_bias, "test config must contain at least one qkv layer"
for bname, exp in expected_bias.items():
assert bname in loaded, f"{bname} bias was not assembled by load_weights"
assert torch.equal(after[bname], exp), f"{bname} does not match the packed q/k/v bias"
def test_wrapper_load_weights_handles_model_prefixed_shards(
self,
mocker: MockerFixture,
loaded_target_classes,
) -> None:
_, _, code_predictor_wrapper, _, _ = loaded_target_classes
cp_config, talker_config = _make_tiny_config(loaded_target_classes)
vllm_config = _make_vllm_config(mocker)
predictor = code_predictor_wrapper(
vllm_config=vllm_config,
config=cp_config,
talker_config=talker_config,
)
common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"]
mocker.patch.object(
common_mod,
"default_weight_loader",
lambda param, weight: param.data.copy_(weight),
)
param_dict = dict(predictor.named_parameters(remove_duplicate=False))
weights: list[tuple[str, torch.Tensor]] = []
expected_fused: dict[str, torch.Tensor] = {}
torch.manual_seed(7)
for name, param in param_dict.items():
if name.endswith(".self_attn.qkv_proj.weight"):
prefix = name[: -len(".qkv_proj.weight")]
q_size = cp_config.num_attention_heads * cp_config.head_dim
kv_size = cp_config.num_key_value_heads * cp_config.head_dim
hidden = cp_config.hidden_size
q_w = torch.randn(q_size, hidden)
k_w = torch.randn(kv_size, hidden)
v_w = torch.randn(kv_size, hidden)
weights.append((f"{prefix}.q_proj.weight", q_w))
weights.append((f"{prefix}.k_proj.weight", k_w))
weights.append((f"{prefix}.v_proj.weight", v_w))
expected_fused[name] = torch.cat([q_w, k_w, v_w], dim=0)
elif name.endswith(".mlp.gate_up_proj.weight"):
prefix = name[: -len(".gate_up_proj.weight")]
inter = cp_config.intermediate_size
hidden = cp_config.hidden_size
gate_w = torch.randn(inter, hidden)
up_w = torch.randn(inter, hidden)
weights.append((f"{prefix}.gate_proj.weight", gate_w))
weights.append((f"{prefix}.up_proj.weight", up_w))
expected_fused[name] = torch.cat([gate_w, up_w], dim=0)
else:
weights.append((name, torch.randn_like(param)))
loaded = predictor.load_weights(weights)
after = dict(predictor.named_parameters(remove_duplicate=False))
assert expected_fused, "test config must contain at least one fused projection layer"
for fname, expected in expected_fused.items():
assert fname in loaded, f"{fname} was not loaded through the outer wrapper"
assert torch.equal(after[fname], expected), f"{fname} does not match the packed shard order"
def test_load_weights_raises_on_incomplete_shards(self, loaded_target_classes) -> None:
"""Missing one of q/k/v (or gate/up) must surface a clear error.
``CodePredictorBaseModel`` is the inner transformer (its parameter
paths look like ``layers.<i>.self_attn.qkv_proj.weight`` -- without
the ``model.`` prefix that the outer ``CodePredictorWrapper`` strips
before forwarding). The test mimics that contract.
"""
_, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
model = code_predictor_model(cp_config, embedding_dim=cp_config.hidden_size)
# Find any actual self_attn layer prefix, to make the test robust
# to renaming of the inner model's parameter scheme.
attn_prefix = next(
(
name[: -len(".qkv_proj.weight")]
for name in dict(model.named_parameters()).keys()
if name.endswith(".self_attn.qkv_proj.weight")
),
None,
)
assert attn_prefix is not None, "test config must contain at least one self_attn layer"
num_heads = cp_config.num_attention_heads
num_kv_heads = cp_config.num_key_value_heads
head_dim = cp_config.head_dim
hidden = cp_config.hidden_size
# Provide only q + k (no v). The fused param exists in the model,
# so load_weights must refuse to silently leave it uninitialized.
bad_weights: list[tuple[str, torch.Tensor]] = [
(f"{attn_prefix}.q_proj.weight", torch.randn(num_heads * head_dim, hidden)),
(f"{attn_prefix}.k_proj.weight", torch.randn(num_kv_heads * head_dim, hidden)),
# v_proj missing on purpose.
]
with pytest.raises(RuntimeError, match="incomplete fused shards"):
model.load_weights(bad_weights)
def test_load_weights_raises_when_fused_shards_absent(self, loaded_target_classes) -> None:
"""Entirely absent q/k/v or gate/up groups must not leave fused params
randomly initialized."""
_, _, _, code_predictor_model, _ = loaded_target_classes
cp_config, _ = _make_tiny_config(loaded_target_classes)
model = code_predictor_model(cp_config, embedding_dim=cp_config.hidden_size)
weights = [
(name, torch.randn_like(param))
for name, param in model.named_parameters(remove_duplicate=False)
if not (name.endswith(".qkv_proj.weight") or name.endswith(".gate_up_proj.weight"))
]
with pytest.raises(RuntimeError, match="missing fused parameters"):
model.load_weights(weights)
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
PersonaPlexTalkerForConditionalGeneration,
)
from vllm_omni.model_executor.stage_input_processors.personaplex import (
talker2code2wav_async_chunk,
talker2code2wav_full_payload,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _raw_agent_codes() -> torch.Tensor:
return torch.arange(16, dtype=torch.long).reshape(2, 8)
@pytest.mark.parametrize("source", ["pooling_output", "additional_information_cpu"])
def test_full_payload_accepts_worker_and_cached_payload_sources(source: str) -> None:
audio = _raw_agent_codes()
payload = talker2code2wav_full_payload(
pooling_output={"codes": {"audio": audio}} if source == "pooling_output" else None,
request=(
SimpleNamespace()
if source == "pooling_output"
else SimpleNamespace(additional_information_cpu={"codes": {"audio": audio}})
),
)
expected = torch.cat([audio[:-1, :1], audio[1:, 1:]], dim=1).reshape(-1)
assert torch.equal(payload.codes.audio, expected)
def test_async_chunk_keeps_delay_tail_across_resumable_segments() -> None:
manager = SimpleNamespace(
connector=SimpleNamespace(
config={
"extra": {
"initial_codec_chunk_frames": 1,
"codec_chunk_frames": 5,
}
}
)
)
request = SimpleNamespace(
request_id="req",
external_req_id="req",
resumable=True,
is_finished=lambda: True,
additional_information=None,
)
first_frame = torch.arange(8, dtype=torch.long).reshape(1, 8)
second_frame = torch.arange(8, 16, dtype=torch.long).reshape(1, 8)
request.additional_information = {"codes": {"audio": first_frame}}
first = talker2code2wav_async_chunk(
manager,
multimodal_output=None,
request=request,
is_finished=True,
)
request.additional_information = {"codes": {"audio": second_frame}}
second = talker2code2wav_async_chunk(
manager,
multimodal_output=None,
request=request,
is_finished=True,
)
assert first is not None
assert first.codes is None
assert first.meta is not None
assert first.meta.finished.item() is False
assert first.meta.is_segment_finished.item() is False
expected = torch.cat([first_frame[:, :1], second_frame[:, 1:]], dim=1).reshape(-1)
assert torch.equal(second.codes.audio, expected)
assert manager.request_payload["req"]["personaplex_frames"][0].equal(second_frame.reshape(-1))
def test_post_sample_talker_mtp_uses_current_temporal_state() -> None:
received: dict[str, torch.Tensor] = {}
recorded: list[tuple[str, torch.Tensor, torch.Tensor]] = []
def depformer(
text_token: torch.Tensor,
hidden: torch.Tensor,
*,
audio_tokens: torch.Tensor | None = None,
audio_provided: torch.Tensor | None = None,
) -> torch.Tensor:
received["text_token"] = text_token
received["hidden"] = hidden
received["audio_tokens"] = audio_tokens
received["audio_provided"] = audio_provided
return torch.arange(16, dtype=torch.long).reshape(1, 16)
model = SimpleNamespace(
_dtype=torch.float32,
depformer=depformer,
_duplex_stage0_runtime=lambda: SimpleNamespace(
record_sample=lambda *, request_id, text_token, agent_codes: recorded.append(
(request_id, text_token.clone(), agent_codes.clone())
)
),
)
method = getattr(PersonaPlexTalkerForConditionalGeneration, "post_sample_talker_mtp", None)
assert callable(method)
codes = method(
model,
input_ids=torch.tensor([101]),
hidden_states=torch.arange(4, dtype=torch.float32).reshape(1, 4),
req_ids=["r1"],
req_infos=[
{
"duplex": {"data_plane": True},
"pplex_depformer_audio_tokens": torch.arange(16),
"pplex_depformer_audio_provided": torch.arange(16) > 0,
}
],
)
assert codes.shape == (1, 16)
assert received["text_token"].tolist() == [101]
assert received["hidden"].shape == (1, 1, 4)
assert torch.equal(received["audio_tokens"], torch.arange(16).reshape(1, 16))
assert torch.equal(received["audio_provided"], (torch.arange(16) > 0).reshape(1, 16))
assert [(row[0], row[1].item()) for row in recorded] == [("r1", 101)]
assert torch.equal(recorded[0][2], codes[0])
+9 -16
View File
@@ -414,21 +414,6 @@ def test_qwen3_tts_code_predictor_forward_uses_projected_embedding_and_sampling(
assert sample_calls == [{0: generator}]
def test_310p_attention_forward_uses_fused_qkv(monkeypatch: pytest.MonkeyPatch) -> None:
"""The fused code predictor dropped q_proj/k_proj/v_proj in favor of a single
``qkv_proj`` (+ ``_split_qkv``). The 310P attention override must follow
suit, otherwise its NPU flash-attention path raises AttributeError on 310P.
The NPU path itself cannot run in CPU CI, so guard the contract statically."""
import inspect
module, _ = _load_qwen3_tts_patch(monkeypatch)
source = inspect.getsource(module._Qwen3CodePredictorAttention310P.forward)
assert "qkv_proj" in source and "_split_qkv" in source
assert "self.q_proj" not in source
assert "self.k_proj" not in source
assert "self.v_proj" not in source
def test_qwen3_tts_talker_patch_uses_fp16_runtime_dtype(monkeypatch: pytest.MonkeyPatch) -> None:
module, _ = _load_qwen3_tts_patch(monkeypatch)
talker = module._Qwen3TTSTalker310P(vllm_config=object())
@@ -504,6 +489,14 @@ def test_qwen3_tts_tokenizer_npu_patch_dispatches_fused_ops(monkeypatch: pytest.
_install_fake_module(monkeypatch, "vllm")
_install_fake_module(monkeypatch, "vllm.logger", init_logger=lambda _name: SimpleNamespace(debug=lambda *_: None))
rotary_path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "layers" / "rotary_embedding.py"
rotary_module = _load_source_module("vllm_omni_test_npu_rotary_embedding", rotary_path)
monkeypatch.setitem(
sys.modules,
"vllm_omni.platforms.npu.layers.rotary_embedding",
rotary_module,
)
class FakeRMSNorm(torch.nn.Module):
def __init__(self):
super().__init__()
@@ -551,7 +544,7 @@ def test_qwen3_tts_tokenizer_npu_patch_dispatches_fused_ops(monkeypatch: pytest.
norm_out = norm(torch.ones(1, 2))
assert len(rotary_calls) == 2
assert rotary_calls[0][1].shape == (1, 1, 3, 2)
assert rotary_calls[0][1].shape == (1, 3, 1, 2)
torch.testing.assert_close(q_out, q + 1)
torch.testing.assert_close(k_out, k + 1)
assert len(rms_calls) == 1
+147
View File
@@ -0,0 +1,147 @@
import pytest
import torch
from vllm_omni.utils import mm_outputs as mm_outputs_mod
from vllm_omni.utils.mm_outputs import build_mm_cpu
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
TOTAL_TOKENS = 5
NUM_TOKEN_ALIGNED_TENSORS = 3 # codes.audio, meta.ref_code_len, meta.codec_streaming
def make_talker_passthrough_payload(num_requests: int, device: str = "cpu") -> dict:
"""The passthrough set qwen3-tts actually hands to build_mm_cpu each decode
step (qwen3_tts_talker.make_omni_output): a nested codes/meta dict whose
``codes.ref`` holds one tensor per request, alongside 1D int token-aligned
metadata. Under prefix caching only ``codes.audio`` is 2D and token-aligned,
so it is the sole cache key and this whole structure is passthrough.
"""
return {
"codes": {
"audio": torch.rand(TOTAL_TOKENS, 4, device=device),
"ref": [torch.arange(idx + 1, dtype=torch.long, device=device) for idx in range(num_requests)],
},
"meta": {
"ref_code_len": torch.full((TOTAL_TOKENS,), 7, dtype=torch.int32, device=device),
"codec_streaming": torch.ones(TOTAL_TOKENS, dtype=torch.int8, device=device),
},
}
def iter_tensors(value):
"""Yield every tensor leaf, recursing the same dict/list nesting _to_cpu does."""
if isinstance(value, torch.Tensor):
yield value
elif isinstance(value, dict):
for item in value.values():
yield from iter_tensors(item)
elif isinstance(value, list):
for item in value:
yield from iter_tensors(item)
@pytest.mark.parametrize("num_requests", [1, 3])
def test_build_mm_cpu_d2h_count_grows_per_request(num_requests, mocker):
"""Pins the pre-W2 cost: build_mm_cpu issues one per-tensor copy for each
token-aligned payload tensor plus one more for every request's ``codes.ref``
entry, so the transfer count grows linearly with batch size. That growth is
what RFC #4855 W2 sets out to batch away.
Counting tensor leaves rather than dict keys is deliberate: a flat dict of
K tensors makes ``call_count == K`` true by construction, since build_mm_cpu
is a loop over items. Only the per-request list can move this number.
"""
spy = mocker.spy(mm_outputs_mod, "_to_cpu")
result = build_mm_cpu(multimodal_outputs=make_talker_passthrough_payload(num_requests))
tensor_copies = [call for call in spy.call_args_list if isinstance(call.args[0], torch.Tensor)]
assert len(tensor_copies) == NUM_TOKEN_ALIGNED_TENSORS + num_requests
assert len(result["codes"]["ref"]) == num_requests
def test_build_mm_cpu_preserves_int_dtypes_of_talker_metadata():
"""ref_code_len is int32 and codec_streaming int8 at the source; a W2 that
stages passthrough through a float buffer would corrupt both silently."""
result = build_mm_cpu(multimodal_outputs=make_talker_passthrough_payload(3))
assert result["meta"]["ref_code_len"].dtype == torch.int32
assert result["meta"]["codec_streaming"].dtype == torch.int8
assert all(ref.dtype == torch.long for ref in result["codes"]["ref"])
def test_build_mm_cpu_returns_cpu_tensors_for_every_nested_leaf():
"""The output contract: every tensor leaf, including those inside the
``codes.ref`` list, comes back on CPU.
On this lane the inputs are already CPU, so this documents the contract
rather than proving it -- ``.to("cpu")`` is a no-op here. The GPU-gated
sibling below is the one that can actually fail.
"""
result = build_mm_cpu(multimodal_outputs=make_talker_passthrough_payload(3))
leaves = list(iter_tensors(result))
assert len(leaves) == NUM_TOKEN_ALIGNED_TENSORS + 3
assert all(leaf.device.type == "cpu" for leaf in leaves)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires a CUDA device")
def test_build_mm_cpu_moves_a_gpu_payload_off_the_device():
"""D2H is the entire purpose of build_mm_cpu. RFC #4855 W2 lists "skip the
CPU conversion when the consumer is the same-GPU next stage" as one of its
three options; that option leaves every tensor on the device and no CPU-lane
test can observe it, because there ``.to("cpu")`` is a silent no-op.
"""
payload = make_talker_passthrough_payload(3, device="cuda")
source_leaves = list(iter_tensors(payload))
assert all(leaf.device.type == "cuda" for leaf in source_leaves)
result = build_mm_cpu(multimodal_outputs=payload)
leaves = list(iter_tensors(result))
assert len(leaves) == len(source_leaves)
assert all(leaf.device.type == "cpu" for leaf in leaves)
def test_build_mm_cpu_empty_input_is_a_noop(mocker):
spy = mocker.spy(mm_outputs_mod, "_to_cpu")
assert build_mm_cpu(multimodal_outputs={}) == {}
spy.assert_not_called()
def test_build_mm_cpu_preserves_nested_and_non_tensor_payload_semantics():
"""_to_cpu must recurse into nested dict/list structures, force
non-contiguous tensors to become contiguous, detach from autograd,
and leave dtype/shape/values unchanged; non-tensor leaves (scalar,
string) pass through untouched. Any W2 implementation (cat, deferral,
or a new materializer) must preserve this exact semantics."""
non_contig = torch.arange(9, dtype=torch.float32).reshape(3, 3).t()
assert not non_contig.is_contiguous()
grad_tensor = torch.tensor([5.0, 6.0], requires_grad=True)
payload = {
"nested": {"inner_tensor": non_contig, "inner_scalar": "keep-me"},
"list_of_tensors": [torch.tensor([1.0, 2.0]), "marker", torch.tensor([3.0, 4.0])],
"grad_key": grad_tensor,
"scalar": 42,
}
result = build_mm_cpu(multimodal_outputs=payload)
inner = result["nested"]["inner_tensor"]
assert inner.is_contiguous()
assert inner.dtype == non_contig.dtype
assert inner.shape == non_contig.shape
assert torch.equal(inner, non_contig)
assert result["nested"]["inner_scalar"] == "keep-me"
lst = result["list_of_tensors"]
assert torch.equal(lst[0], torch.tensor([1.0, 2.0]))
assert lst[1] == "marker"
assert torch.equal(lst[2], torch.tensor([3.0, 4.0]))
assert result["grad_key"].requires_grad is False
assert torch.equal(result["grad_key"], grad_tensor.detach())
assert result["scalar"] == 42
+291
View File
@@ -1,4 +1,5 @@
import asyncio
import copy
from collections.abc import Sequence
from types import SimpleNamespace
@@ -32,6 +33,160 @@ def _make_runner(engine_output_type: str | None, downstream_req_ids: set[str]) -
return runner
def _mtp_runner(*, async_scheduling: bool, buffers: dict[str, dict]) -> tuple[GPUARModelRunner, dict[str, object]]:
received: dict[str, object] = {}
def post_sample_talker_mtp(*, input_ids, hidden_states, req_ids, req_infos):
received.update(
input_ids=input_ids.clone(),
hidden_states=hidden_states.clone(),
req_ids=list(req_ids),
req_infos=copy.deepcopy(req_infos),
)
return torch.tensor([[11, 12, 13]], dtype=torch.long)
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = async_scheduling
runner.model = SimpleNamespace(post_sample_talker_mtp=post_sample_talker_mtp)
runner.requests = {req_id: SimpleNamespace() for req_id in buffers}
runner.model_intermediate_buffer = buffers
return runner, received
def test_post_sample_talker_mtp_uses_current_sample_and_hidden() -> None:
runner, received = _mtp_runner(
async_scheduling=False,
buffers={
"ready": {"duplex": {"data_plane": True}},
"partial-prefill": {
"duplex": {"data_plane": True},
"codes": {"audio": torch.tensor([[99]])},
},
"offline": {},
},
)
multimodal = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["ready", "partial-prefill", "offline"],
valid_sampled_token_ids=[[101], [], [102]],
sampled_token_ids=torch.tensor([[101], [0], [102]], dtype=torch.long),
invalid_req_indices=[],
sample_hidden_states=torch.tensor(
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
),
multimodal_outputs={
"codes": {
"audio": torch.tensor(
[[70, 71, 72], [80, 81, 82], [90, 91, 92]],
dtype=torch.long,
)
},
"meta": {"source": "temporal"},
},
)
assert received["req_ids"] == ["ready"]
assert received["input_ids"].tolist() == [101]
assert received["hidden_states"].tolist() == [[1.0, 2.0]]
assert received["req_infos"] == [{"duplex": {"data_plane": True}}]
assert runner.model_intermediate_buffer["ready"]["codes"]["audio"].tolist() == [[11, 12, 13]]
# A partial-prefill row has no sampled token yet. It must not replay the
# prior frame's codes into the current connector payload.
audio = multimodal["codes"]["audio"]
assert audio[0].tolist() == [[11, 12, 13]]
assert audio[1].numel() == 0
# Mixed batches must retain outputs produced by non-duplex rows.
assert audio[2].tolist() == [[90, 91, 92]]
assert multimodal["meta"] == {"source": "temporal"}
def test_post_sample_talker_mtp_uses_gpu_token_with_async_scheduling() -> None:
runner, received = _mtp_runner(
async_scheduling=True,
buffers={
"ready": {"duplex": {"data_plane": True}},
"discarded-prefill": {"duplex": {"data_plane": True}},
},
)
multimodal = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["ready", "discarded-prefill"],
valid_sampled_token_ids=[],
sampled_token_ids=torch.tensor([[101], [102]], dtype=torch.long),
invalid_req_indices=[1],
sample_hidden_states=torch.tensor(
[[1.0, 2.0], [3.0, 4.0]],
),
multimodal_outputs=None,
)
assert received["req_ids"] == ["ready"]
assert received["input_ids"].tolist() == [101]
assert received["hidden_states"].tolist() == [[1.0, 2.0]]
assert received["req_infos"] == [{"duplex": {"data_plane": True}}]
assert multimodal["codes"]["audio"][0].tolist() == [[11, 12, 13]]
assert multimodal["codes"]["audio"][1].numel() == 0
def test_post_sample_talker_mtp_skips_shape_validation_without_duplex_rows() -> None:
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = False
runner.model = SimpleNamespace(
post_sample_talker_mtp=lambda **_: pytest.fail("hook must not run"),
)
runner.model_intermediate_buffer = {"offline": {}}
multimodal = {"codes": {"audio": torch.tensor([[7, 8]], dtype=torch.long)}}
result = GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["offline"],
valid_sampled_token_ids=[[101, 102]],
sampled_token_ids=torch.tensor([[101, 102]], dtype=torch.long),
invalid_req_indices=[],
sample_hidden_states=torch.tensor([[1.0, 2.0]]),
multimodal_outputs=multimodal,
)
assert result is multimodal
@pytest.mark.parametrize(
("sampled_token_ids", "error_match"),
[
(torch.tensor([101], dtype=torch.long), "rows do not match requests"),
(
torch.tensor([[101, 102]], dtype=torch.long),
"does not support speculative token rows",
),
],
)
def test_post_sample_talker_mtp_rejects_invalid_selected_token_shape(
sampled_token_ids: torch.Tensor,
error_match: str,
) -> None:
runner = object.__new__(GPUARModelRunner)
runner.use_async_scheduling = False
runner.model = SimpleNamespace(
post_sample_talker_mtp=lambda **_: pytest.fail("hook must not run"),
)
runner.model_intermediate_buffer = {
"duplex": {"duplex": {"data_plane": True}},
}
with pytest.raises(ValueError, match=error_match):
GPUARModelRunner._run_post_sample_talker_mtp(
runner,
req_ids=["duplex"],
valid_sampled_token_ids=[[101]],
sampled_token_ids=sampled_token_ids,
invalid_req_indices=[],
sample_hidden_states=torch.tensor([[1.0, 2.0]]),
multimodal_outputs=None,
)
def test_speech_extra_params_reach_model_sampler_as_sampling_metadata(monkeypatch):
requested = {"temperature": 0.7, "top_p": 0.8, "top_k": 17}
request = OpenAICreateSpeechRequest.model_validate(
@@ -897,3 +1052,139 @@ def test_build_omni_output_splits_client_mm_from_inter_stage_keys(monkeypatch):
assert output.inter_stage_outputs is not None
assert "hidden" in output.inter_stage_outputs[0]
assert "audio" not in output.inter_stage_outputs[0]
def test_build_omni_output_filters_multimodal_by_partial_downstream_batch(monkeypatch):
"""When only a subset of req_ids_output_copy need the downstream
pooler payload, multimodal_outputs (not just hidden_states, which
test_build_omni_output_copies_hidden_for_partial_downstream_batch
already covers) must be attached only to the downstream requests, in
the right per-request slot -- a skipped middle request must not shift
a later request's slice onto the wrong index."""
runner = _make_async_output_runner(engine_output_type="latent")
runner.requests = {"r1": object(), "r2": object(), "r3": object()}
runner.omni_prefix_cache = object()
monkeypatch.setattr(
GPUARModelRunner,
"_resolve_pooler_payload_req_ids",
lambda self, req_ids: ("latent", ["r1", "r3"]),
)
monkeypatch.setattr(GPUARModelRunner, "_model_needs_full_prefix_hidden_states", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "_deferred_prefix_cache_mm_keys", lambda self: {"codes.audio"})
monkeypatch.setattr(GPUARModelRunner, "_stage_deferred_prefix_cache_mm_outputs", lambda *args, **kwargs: None)
monkeypatch.setattr(
GPUARModelRunner,
"_prepare_prefix_cache_pooler_payload_sources",
lambda *args, **kwargs: (None, None, None),
)
monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None)
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
codes = torch.tensor([[11.0, 12.0], [21.0, 22.0], [31.0, 32.0]], dtype=torch.float32)
output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot(
runner,
scheduler_output=SimpleNamespace(
total_num_scheduled_tokens=3,
num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1},
),
hidden_states=torch.tensor([[1.0], [2.0], [3.0]]),
staged_hidden_states_cpu=None,
multimodal_outputs={"codes.audio": codes},
req_ids_output_copy=["r1", "r2", "r3"],
req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2},
valid_sampled_token_ids=[[], [], []],
logprobs_lists=None,
prompt_logprobs_dict={},
num_nans_in_logits=None,
kv_connector_output=None,
ec_connector_output=None,
cudagraph_stats=None,
kv_extracted_req_ids=None,
num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long),
)
# Exact indices pin the mapping: r3 keeps slot 2 rather than shifting into
# the skipped r2's slot.
assert torch.equal(output.inter_stage_outputs[0]["codes.audio"], codes[0:1])
assert output.inter_stage_outputs[1] is None
assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], codes[2:3])
def test_build_omni_output_uses_combined_prefix_cache_mm_payload_for_partial_downstream_batch(monkeypatch):
"""When the prefix-cache merge path is engaged (i.e.
_prepare_prefix_cache_pooler_payload_sources returns a non-None
combined_multimodal_outputs), _build_omni_mm_payload must take the
_build_combined_prefix_cache_mm_payload branch -- keyed by req_id,
not by row/slice -- rather than falling back to build_mm_cpu, and
must still map correctly onto a partial downstream batch. This is a
distinct code path from test_build_omni_output_filters_multimodal_by_partial_downstream_batch,
which only exercises the no-prefix-cache-merge (mm_cpu) fallback.
The merge itself is stubbed out here; what this pins is the branch selection
plus _unwrap_lists' batch-index selection for a list-valued key.
"""
runner = _make_async_output_runner(engine_output_type="latent")
runner.requests = {"r1": object(), "r2": object(), "r3": object()}
runner.omni_prefix_cache = object()
# codes.ref arrives whole for every request: the prefix-cache merge uses
# pass_lists_through, so selecting this request's entry is _unwrap_lists'
# job, and it selects by batch index (r3 -> 2), not by downstream position.
ref_codes = [torch.tensor([1]), torch.tensor([2]), torch.tensor([3])]
combined_mm = {
"codes.audio": {
"r1": torch.tensor([[11.0, 12.0]]),
"r3": torch.tensor([[31.0, 32.0]]),
},
"codes.ref": {"r1": list(ref_codes), "r3": list(ref_codes)},
}
monkeypatch.setattr(
GPUARModelRunner,
"_resolve_pooler_payload_req_ids",
lambda self, req_ids: ("latent", ["r1", "r3"]),
)
monkeypatch.setattr(GPUARModelRunner, "_model_needs_full_prefix_hidden_states", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "_deferred_prefix_cache_mm_keys", lambda self: {"codes.audio"})
monkeypatch.setattr(GPUARModelRunner, "_stage_deferred_prefix_cache_mm_outputs", lambda *args, **kwargs: None)
monkeypatch.setattr(
GPUARModelRunner,
"_prepare_prefix_cache_pooler_payload_sources",
lambda *args, **kwargs: (None, None, combined_mm),
)
monkeypatch.setattr(GPUARModelRunner, "_process_additional_information_updates", lambda *args, **kwargs: None)
monkeypatch.setattr(GPUARModelRunner, "_should_accumulate_full_payload_output", lambda self: False)
monkeypatch.setattr(GPUARModelRunner, "get_omni_connector_output", lambda self: None)
output = GPUARModelRunner._build_omni_model_runner_output_from_snapshot(
runner,
scheduler_output=SimpleNamespace(
total_num_scheduled_tokens=3,
num_scheduled_tokens={"r1": 1, "r2": 1, "r3": 1},
),
hidden_states=torch.tensor([[1.0], [2.0], [3.0]]),
staged_hidden_states_cpu=None,
multimodal_outputs={},
req_ids_output_copy=["r1", "r2", "r3"],
req_id_to_index_output_copy={"r1": 0, "r2": 1, "r3": 2},
valid_sampled_token_ids=[[], [], []],
logprobs_lists=None,
prompt_logprobs_dict={},
num_nans_in_logits=None,
kv_connector_output=None,
ec_connector_output=None,
cudagraph_stats=None,
kv_extracted_req_ids=None,
num_scheduled_tokens_np=np.array([1, 1, 1], dtype=np.int32),
query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.long),
)
assert torch.equal(output.inter_stage_outputs[0]["codes.audio"], combined_mm["codes.audio"]["r1"])
assert output.inter_stage_outputs[1] is None
assert torch.equal(output.inter_stage_outputs[2]["codes.audio"], combined_mm["codes.audio"]["r3"])
assert torch.equal(output.inter_stage_outputs[0]["codes.ref"], ref_codes[0])
assert torch.equal(output.inter_stage_outputs[2]["codes.ref"], ref_codes[2])
+28
View File
@@ -105,6 +105,34 @@ class _FakeTPGroup:
# ------------------------------------------------------------------ #
@pytest.mark.parametrize(
("role", "custom_func", "expected"),
[
("sender", None, False),
("sender", "package.build_payload", True),
("receiver", None, True),
],
)
def test_init_payload_connector_ownership(role, custom_func, expected):
model_config = _make_model_config(custom_func=custom_func)
model_config.stage_connector_config = {
"name": "MooncakeTransferEngineConnector",
"extra": {"role": role},
}
host = MixinHost()
connector = MockConnector()
with (
patch.object(host, "_create_connector", return_value=connector) as create,
patch.object(host, "_load_custom_func", return_value=(None, None)),
):
host.init_omni_connectors(model_config)
assert (create.call_count == 1) is expected
assert (host._omni_connector is connector) is expected
host.shutdown_omni_connectors()
class TestMixinAsyncChunkSendRecv(unittest.TestCase):
"""Test 2: Async chunk send/recv + bg threads."""
+1
View File
@@ -122,6 +122,7 @@ class OmniModelConfig(ModelConfig):
retains_state_across_chunks: bool = False
# Stage-1 active stream slots; 0 keeps legacy chunk-level round-robin.
active_stream_window: int = 0
duplex_max_sessions: int = 1
model_stage: str = "thinker"
model_arch: str | None = None
worker_type: str | None = None
+11 -1
View File
@@ -258,6 +258,7 @@ class OmniStageModelConfig:
"""Per-stage model behavior."""
active_stream_window: int = Field(default=0, ge=0)
duplex_max_sessions: int = Field(default=1, ge=1)
enable_sleep_mode: bool = False
default_sampling_params: dict[str, Any] | None = None
subtalker_sampling_params: dict[str, Any] | None = None
@@ -478,6 +479,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)
@@ -968,7 +970,12 @@ def _build_common_stage_config_kwargs(
return (
{
"stage_pipeline_config": topology,
"model_config": _build_model_config(topology, stage_deploy, engine.model),
"model_config": _build_model_config(
topology,
stage_deploy,
engine.model,
duplex_max_sessions=(deploy.duplex_session.max_sessions if deploy.session_mode == "duplex" else 1),
),
"load_config": _build_load_config(engine.load),
"cache_config": _build_cache_config(deploy, engine.cache),
"scheduler_config": _build_scheduler_config(deploy, engine.scheduler),
@@ -1121,6 +1128,8 @@ def _build_model_config(
topology: StagePipelineConfig,
stage_deploy: StageDeployConfig | None,
engine: _ModelEngineOverrides,
*,
duplex_max_sessions: int,
) -> OmniStageModelConfig:
default_sampling_params = _stage_sampling_params(stage_deploy, topology)
kwargs = _config_kwargs(engine)
@@ -1132,6 +1141,7 @@ def _build_model_config(
kwargs["tokenizer_subdir"] = topology.tokenizer_subdir
return OmniStageModelConfig(
default_sampling_params=default_sampling_params,
duplex_max_sessions=duplex_max_sessions,
**kwargs,
)
+2
View File
@@ -86,6 +86,7 @@ from vllm_omni.model_executor.models.moss_tts.pipeline import (
)
from vllm_omni.model_executor.models.moss_tts_nano.pipeline import MOSS_TTS_NANO_PIPELINE
from vllm_omni.model_executor.models.omnivoice.pipeline import OMNIVOICE_PIPELINE
from vllm_omni.model_executor.models.personaplex.pipeline import PERSONAPLEX_PIPELINE
from vllm_omni.model_executor.models.qwen2_5_omni.pipeline import (
QWEN2_5_OMNI_PIPELINE,
QWEN2_5_OMNI_THINKER_ONLY_PIPELINE,
@@ -113,6 +114,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = {
"aura_omni": AURA_OMNI_PIPELINE,
"qwen2_5_omni": QWEN2_5_OMNI_PIPELINE,
"qwen2_5_omni_thinker_only": QWEN2_5_OMNI_THINKER_ONLY_PIPELINE,
"personaplex": PERSONAPLEX_PIPELINE,
"qwen3_omni_moe": resolve_qwen3_omni_pipeline,
"qwen3_tts": QWEN3_TTS_PIPELINE,
"step_audio_2": STEP_AUDIO2_PIPELINE,
+8
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
@@ -857,6 +860,11 @@ def _build_engine_args(
# Materialize the resolved pipeline-wide async_chunk value into every
# stage so explicit False overrides do not get lost downstream.
engine_args["async_chunk"] = bool(deploy.async_chunk)
if deploy.session_mode == "duplex":
# The engine admission limit is also the authoritative capacity for
# model-owned streaming state. Propagate it to every stage instead of
# making individual models duplicate the value in connector extras.
engine_args["duplex_max_sessions"] = deploy.duplex_session.max_sessions
if ps.omni_kv_config:
engine_args["omni_kv_config"] = dict(ps.omni_kv_config)
return engine_args
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more