Compare commits

...
Author SHA1 Message Date
Taichang ZhouandClaude Opus 4.8 9678ae58cf [MR V2] Migrate to Model Runner V2
Squashed migration rebased onto current main. Collapses the 46-commit
dev/migrate-MR-v2 history (10 merge commits + superseded pre-rebase work)
into a single commit, so the diff reflects only the genuine MR-V2 feature
(worker_v2/ runners + model_states, omni schedulers, stage engine pipeline,
pooling_output serialization bridge). Content already merged to main
separately (step_audio2, tts_adapters, npu/_310p, etc.) drops out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 07:19:40 +00:00
cead615288 [Bugfix][Qwen3-Omni] Fix Code2Wav CUDA-graph output length surplus (#4466) (#4525)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
Co-authored-by: Sy03 <1370724210@qq.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-19 23:58:45 -07:00
Zhengyuan Su (苏政渊) 17cf60a63d [Bugfix] Align vLLM Omni Request input signature with vLLM. (#4568)
Signed-off-by: Zhengyuan Su <su.zhengyuan@u.nus.edu>
2026-06-20 04:25:43 +00:00
Zhengyuan Su (苏政渊)andClaude Opus 4.8 2d0b1d8a29 [Diffusion] add GR00T-N1.7 pipeline with OpenPI serving (#3798)
Signed-off-by: Zhengyuan Su <su.zhengyuan@u.nus.edu>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:45:26 +08:00
TJian e70822ed54 [ROCm] [CI] Fix diffusion test (#4574)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-06-20 08:31:39 +08:00
Alex Brooks 6ff246bf3c [Cache Refactor 1/N] Simplify CacheDiT Integration (#2527)
Signed-off-by: Alex Brooks <albrooks@redhat.com>
2026-06-20 08:30:44 +08:00
Ian Eaves 951ae28eb0 [Frontend] Configurable video storage backends with TTL (#2531)
Signed-off-by: Ian Eaves <ian.k.eaves@gmail.com>
2026-06-19 17:41:22 -06:00
Kwokho TsuiandKwokho Tsui 7e152ca64b [Bugfix] Fix HSDP + FP8 online quantization compatibility (#4494)
Signed-off-by: Kwokho Tsui <kh_tsui@163.com>
Co-authored-by: Kwokho Tsui <kh_tsui@163.com>
2026-06-19 15:06:27 +08:00
Yueqian Lin 208932210f [Chore][TTS] Reference recipes.vllm.ai recipes, remove dead code, and fix stale references (#4567)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-06-19 15:05:26 +08:00
amy-why-3459 f05e03aea8 [Perf][DreamZero]dreamzero supports CUDA graph, torch.compile, and DiT Caching. (#4213)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-06-19 14:24:37 +08:00
yangyonggit 63ba11b8b1 [Perf] Stacked audio ops: batched matmul and embedding gather in MOSS-TTS talker (#4230)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
2026-06-19 00:38:32 +00:00
yangyonggit 37a442447e [Cleanup] Remove duplicate HiDreamI1ImagePipeline (fixes #4009) (#4045)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
2026-06-19 06:32:18 +08:00
yangyonggit 13e9094be0 [Doc] Add 1x RTX A6000 48GB section to Voxtral TTS recipe (#2645) (#4051)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
2026-06-19 06:27:26 +08:00
yangyonggitandYueqian Lin 1228a109f0 [Bugfix] MOSS-TTS: fix cross-request audio corruption under batching (#4415)
Signed-off-by: leo.yang <leo.yang.engineer@gmail.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
2026-06-18 18:11:51 +00:00
Honghan Zhu 8f28b969e9 [Feat] DreamZero fix tp & cross attn cache (#4154) 2026-06-18 10:57:04 +00:00
zhumingjue138 2d2258890b [CI] skip ci for issue 4537 (#4538)
Signed-off-by: zhumingjue <zhumingjue@huawei.com>
2026-06-18 17:53:34 +08:00
Zening Chen 0383fde38a [Doc] Qwen3-TTS: add 0.6B on 1x RTX 4090 and fix broken links (#4026)
Signed-off-by: Zening Chen <59945561+zeningc@users.noreply.github.com>
Signed-off-by: zeningc <zening.chen@yahoo.com>
2026-06-18 17:31:36 +08:00
f75c437a60 [Refactor]Base class for dit pipelines with unified parameter declaration[1/N] (#4225)
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 17:14:18 +08:00
wangyu dff8cda844 [SKILLS] Add vllm-omni-test agent skill for CI-aligned test generation (#4434)
Signed-off-by: wangyu <410167048@qq.com>
Signed-off-by: [Your Name] <[Your Email]>
2026-06-18 17:01:24 +08:00
Wallbreazzz c782746133 Add NPU support for OmniMRotaryEmbedding (#3609)
Signed-off-by: Wallbreazzz <110282866+Wallbreazzz@users.noreply.github.com>
2026-06-18 16:58:40 +08:00
Alicia db8bc2c71d Update VoxCPM and Qwen3TTS perf baseline. (#4521)
Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-06-18 16:56:13 +08:00
bjf-frz 14d70d9870 [Perf]Enable regional compile for Cosmos3 HSDP blocks (#4485)
Signed-off-by: bjf-frz <frz123db@gmail.com>
2026-06-18 16:54:05 +08:00
77d395150b Remove unregister_vllm_metrics no-op patch (#4396)
Signed-off-by: vraiti <vraiti@redhat.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-06-18 10:13:49 +08:00
汪志鹏 f195e44bc1 [BugFix]: Fix Bagel generate_image denoising schedule (#4509) 2026-06-18 02:02:38 +00:00
Yihong Jin 2f64c712fd [Quantization][Qwen3-Omni] Enable NVFP4 W4A4 serving on Blackwell (#4025)
Signed-off-by: Yihong Jin <yihongjin404@gmail.com>
2026-06-18 10:01:46 +08:00
Wallbreazzz 65bc737fd4 [DEBUG]Add has_preprocess branch to _dummy_run for models with preprocess stage (#4189)
Signed-off-by: Wallbreazzz <110282866+Wallbreazzz@users.noreply.github.com>
2026-06-18 07:56:19 +08:00
NumberWanandlishunyang12 d744940b18 Refactor: extract OmniStreamingVideoHandler base and QwenOmniStreamingVideoHandler (#4424)
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
Signed-off-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
Co-authored-by: lishunyang12 <125541396+lishunyang12@users.noreply.github.com>
2026-06-17 15:07:45 +00:00
Nick CaoandClaude e957d16f8d [Refactor] Migrate existing pipelines to use SupportsComponentDiscovery for offload discovery (#3076)
Signed-off-by: Nick Cao <ncao@redhat.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 21:33:06 +08:00
blondeandClaude Opus 4.6 8fa0394d29 [Bugfix] Wire log_stats to AsyncOmni and add missing token metrics for non-text requests (#4482)
Signed-off-by: blonde <164845202+blondeCS@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-17 21:31:44 +08:00
akshatvishu ed37d8e7a2 [Docs] Fix hallucinated stage CLI flags in documentation and comments (#4512)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-06-17 20:49:56 +08:00
akshatvishu d78a92ec54 [bugfix] Shim PreTrainedModel._tp_plan to fix MOSS-TTS-Nano load crash (#4398)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-06-17 17:17:03 +08:00
679cbe226a [Doc] Qwen image 2512 recipe (#4236)
Signed-off-by: dph97 <nenbaying@163.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: zhuhonghan <z00806815@china.huawei.com>
2026-06-17 17:04:58 +08:00
LHXuuuandHongsheng Liu dff757da9c [Perf][PrefixCache] Avoid per-step blocking write in OmniTensorPrefix… (#4106)
Signed-off-by: LHXuuu <xulianhao.xlh@antgroup.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-17 16:53:47 +08:00
KristofferandHongsheng Liu 890803ca06 [Wan2.2] Fix "uninitialized nn.Module of type RotaryEmbeddingWan" graph-break (#4053)
Signed-off-by: Kristoffer Torp <kristoffer.torp@amd.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-17 15:48:22 +08:00
64a5bb92de [Refactor] Omni Stage Runtime and Distributed Replica Control Plane (#3855)
Signed-off-by: yinpeiqi <yinpeiqi809@gmail.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Chenguang ZHENG <645327136@qq.com>
2026-06-17 14:13:20 +08:00
NATUREandAlicia 83e3b129f0 [Bugfix] Fix missing memory metrics info for bagel (ar->dit diffusion) (#4477)
Signed-off-by: natureofnature <wzliu@connect.hku.hk>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-06-17 14:06:14 +08:00
1761253005 [CI] Update perf json baselines (#4289)
Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com>
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
Signed-off-by: herotai214 <herotai214@gmail.com>
Signed-off-by: bjf-frz <frz123db@gmail.com>
Signed-off-by: dengyunyang <584797741@qq.com>
Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
Signed-off-by: CHEN <116010019@link.cuhk.edu.cn>
Signed-off-by: NumberWan <wantszkin2003@gmail.com>
Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
Co-authored-by: herotai214 <herotai214@gmail.com>
Co-authored-by: bjf-frz <frz123db@gmail.com>
Co-authored-by: dengyunyang <584797741@qq.com>
Co-authored-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com>
Co-authored-by: CHEN <116010019@link.cuhk.edu.cn>
Co-authored-by: rein yang <73573651+R2-Y@users.noreply.github.com>
Co-authored-by: NumberWan <wantszkin2003@gmail.com>
2026-06-17 14:02:50 +08:00
amy-why-3459 d897852258 [BugFix] Fix the issue of incorrect generated tokens (#4461)
Signed-off-by: amy-why-3459 <wuhaiyan17@huawei.com>
2026-06-17 02:31:48 +00:00
b46c5af06f [Doc] Add guides for custom docker image build on NVIDIA CUDA [Skip-CI] (#1386)
Signed-off-by: Hyoseop Song <crad_on25@naver.com>
Signed-off-by: Hyoseop Song  <crad_on25@naver.com>
Co-authored-by: Zhou Taichang <tzhouam@connect.ust.hk>
Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com>
2026-06-16 23:15:58 +08:00
f200e988a6 [fix bug] add default limit for image generation (#3381)
Signed-off-by: Junhong Liu <98734602+LJH-LBJ@users.noreply.github.com>
Signed-off-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: lishunyang <lishunyang12@163.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 22:54:26 +08:00
KristofferandHongsheng Liu e3621ec633 [Perf][qwen-image-edit] Skip attention-mask to avoid varlen path (#4293)
Signed-off-by: kTorp <Kristoffer.Torp@amd.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-16 22:52:04 +08:00
Chendi.XueandHongsheng Liu e7f0db1067 [SDXL] SDXL model enabling (#4331)
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-16 22:51:11 +08:00
Nick Cao 864d22164c [Bugfix] Fix DFlash prefix cache corruption due to missing lookahead block (#4449)
Signed-off-by: Nick Cao <ncao@redhat.com>
2026-06-16 22:48:01 +08:00
Samit 5f5b77aa9c [BugFix][Qwen-Image] align txt_seq_lens RoPE width with padded embeds (#4474)
Signed-off-by: samithuang <285365963@qq.com>
2026-06-16 19:09:25 +08:00
Weiming LiaoandCanlin Guo 3fa8817fc5 [BugFix][NPU]: Fix npu model runner too many values to unpack ($[1][0][4]) (#4454)
Signed-off-by: Weiming Liao <liaowm5@gmail.com>
Co-authored-by: Canlin Guo <canlinguosdu@gmail.com>
2026-06-16 19:06:51 +08:00
Zhou TaichangandClaude Opus 4.8 a62420450c [CI][Rebase]: run full e2e suite in rebase Ready/Merge pipelines in the rebase pipeline[skip-ci] (#4478)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:49:19 +08:00
TJian 5cce797c03 [Bugfix] [CI] [ROCm] [CUDA] Fix voxtral test (#4380)
Signed-off-by: tjtanaa <tunjian.tan@embeddedllm.com>
2026-06-16 16:39:23 +08:00
akshatvishu d8a72be206 fix(models): remove invalid sampling_metadata parameter from logits_processor (#4456)
Signed-off-by: akshatvishu <akshatnayak197@gmail.com>
2026-06-16 14:41:40 +08:00
Zheng WengangandHongsheng Liu ba4932c364 [Bugfix] Fix multi-replica stage identity and clean up redundant deploy config (#4410)
Signed-off-by: ZhengWG <zwg0606@gmail.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-16 14:08:11 +08:00
Yuekai ZhangandClaude Opus 4.8 dc14380796 [BugFix] higgs-audio-v3: load codec lazily under load_format=dummy (#4368)
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:07:16 +08:00
Kwokho TsuiandKwokho 3bce5e2e3a [Bugfix] Cosmos3: fix HSDP dtype mismatch on time_embedder via _hsdp_ignored_modules (#4452)
Signed-off-by: Kwokho <kh_tsui@163.com>
Co-authored-by: Kwokho <kh_tsui@163.com>
2026-06-16 04:06:14 +00:00
46e0b71423 [Enhancement] Introduce High-Performance MoT (Mixture-of-Tokens) Kernels: Triton Implementation (#3960)
Signed-off-by: Yufeng Zhu <yzhu802@gatech.edu>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: Zhengyuan Su <su.zhengyuan@u.nus.edu>
Co-authored-by: Yufeng Zhu <yzhu802@gatech.edu>
Co-authored-by: Zhengyuan Su <su.zhengyuan@u.nus.edu>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 03:08:02 +00:00
Chendi.Xue c58a02c873 [XPU][CI] Fix ERROR ...test_glm_image_sp.py - Failed: 'sp' not found in markers (#4451)
Signed-off-by: Chendi Xue <chendi.xue@intel.com>
2026-06-16 05:31:04 +08:00
dengyunyang a86c333c7e [Perf][HunyuanImage] remove sync logic (#4401)
Signed-off-by: dengyunyang <584797741@qq.com>
2026-06-16 05:28:51 +08:00
Hongsheng Liu aff0af9754 [Refactor] Extract diffusion output formatting boundary (#4407)
Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
2026-06-15 22:50:50 +08:00
wangyu b1a4b08a7e [CI/Build] Skip L2/L3 CI for pytest skip-mark changes (#4422)
Signed-off-by: wangyu <410167048@qq.com>
2026-06-15 22:21:13 +08:00
Yuzhu Dong e2e79177db Support DROID policy server for Cosmos3 OpenPI (#4282)
Signed-off-by: yuzhud <yuzhudong@gmail.com>
2026-06-15 21:22:59 +08:00
ae937320ff [Feature] Add HunyuanImage3 DiT grouped step batching (#4041)
Signed-off-by: TaffyOfficial <2324465096@qq.com>
Signed-off-by: zuiho <2324465096@qq.com>
Co-authored-by: TaffyOfficial <2324465096@qq.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-15 20:36:41 +08:00
Mu GuanLin 11829ccd75 [Bugfix] Fix LTX-2.3 tensor-parallel gated attention (#4439)
Signed-off-by: mglyn <1203789601@qq.com>
2026-06-15 11:40:06 +00:00
Yueqian Lin a693ae67f6 [Bugfix] Qwen3-TTS: trim reference audio in no_async_chunk voice clone (#4429)
Signed-off-by: Yueqian Lin <linyueqian@outlook.com>
2026-06-15 18:00:03 +08:00
WeiQing Chen 3c62c53f59 Update WeChat QR code (#4431)
Signed-off-by: David Chen <530634352@qq.com>
2026-06-15 15:52:22 +08:00
rein yang f8340d078e [Perf] change omniconnector info log to debug log to avoid mass printing (#4426)
Signed-off-by: R2-Y <ruiruyang2@gmail.com>
2026-06-15 12:19:34 +08:00
Guanzhe (Leo) Huang c9d5545b52 [Doc] validate Cosmos3-Nano online FP8 (#4393)
Signed-off-by: leohuang257 <masugchds@gmail.com>
2026-06-15 03:36:19 +00:00
Zeng ChuangandHongsheng Liu 10404e4c5b [Perf] Restore parallel stage initialization for AR+DiT pipelines (#3641)
Signed-off-by: zengchuang <zengchuang3@huawei.com>
Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
2026-06-15 09:37:11 +08:00
Ting FU 6c6fde3e6d [BugFix] Fix HunyuanImage3 size match issue (#4416)
Signed-off-by: Semmer <semmer@live.cn>
2026-06-15 09:36:39 +08:00
439 changed files with 37643 additions and 10648 deletions

No files matched your search

+6 -5
View File
@@ -1,11 +1,12 @@
# Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only skip-ci
# from git diff, then uploads document 2. When docs-only skip applies, image-build still runs for the nightly
# exception in upload_pipeline.py (nightly-test label or main NIGHTLY=1); otherwise upload-nightly
# can be skipped together with test-ready/test-merge. Weekly uploads also depend on image-build.
# Document 1: Buildkite loads only this block on first parse. The next step resolves docs-only,
# pytest skip-mark-only, or combined skip-ci from git diff, then uploads document 2. When skip-ci
# applies, image-build still runs for the nightly exception in upload_pipeline.py (nightly-test
# label or main NIGHTLY=1); otherwise upload-nightly can be skipped together with test-ready/test-merge.
# Weekly uploads also depend on image-build.
# Document 2: appended after `---`; same file, read by upload_pipeline.py (not evaluated as a second pipeline by Buildkite). Child uploads include test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml.
steps:
- label: ":github: Resolve skip-ci & upload pipeline"
- label: ":github: Resolve skip-ci (docs / skip marks) & upload pipeline"
key: upload-ci-pipeline
commands:
- "python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/pipeline.yml"
+2 -2
View File
@@ -14,7 +14,7 @@ steps:
depends_on: image-build
key: upload-ready-pipeline
commands:
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-ready.yml
- buildkite-agent pipeline upload .buildkite/test-ready.yml
agents:
queue: "cpu_queue_premerge"
@@ -23,7 +23,7 @@ steps:
depends_on: image-build
key: upload-merge-pipeline
commands:
- python3 .buildkite/scripts/upload_pipeline.py --upload .buildkite/test-merge.yml
- buildkite-agent pipeline upload .buildkite/test-merge.yml
agents:
queue: "cpu_queue_premerge"
@@ -64,7 +64,8 @@ container_name="rocm_${BUILDKITE_COMMIT}_$(tr -dc A-Za-z0-9 < /dev/urandom | hea
docker pull "${image_name}"
remove_docker_container() {
docker rm -f "${container_name}" || docker image rm -f "${image_name}" || true
docker ps -aq --filter "name=${container_name}" | xargs -r docker rm -f || true
docker image rm -f "${image_name}" || true
}
trap remove_docker_container EXIT
@@ -107,7 +108,6 @@ if [[ $commands == *"--shard-id="* ]]; then
--network=host \
--shm-size=16gb \
--group-add "$render_gid" \
--rm \
-e MIOPEN_DEBUG_CONV_DIRECT=0 \
-e MIOPEN_DEBUG_CONV_GEMM=0 \
-e VLLM_ROCM_USE_AITER=0 \
@@ -140,7 +140,6 @@ else
--network=host \
--shm-size=16gb \
--group-add "$render_gid" \
--rm \
-e MIOPEN_DEBUG_CONV_DIRECT=0 \
-e MIOPEN_DEBUG_CONV_GEMM=0 \
-e VLLM_ROCM_USE_AITER=0 \
+206 -15
View File
@@ -3,7 +3,8 @@
"""Render and optionally upload Buildkite pipeline YAML with diff-aware logic.
Bootstrap mode (pipeline.yml with __IMAGE_BUILD_IF__ placeholders):
- Detect docs-only PR/main changes and substitute skip-ci ``if`` expressions.
- Detect docs-only, pytest skip-mark-only, or combined docs+skip-mark PR/main changes and
substitute skip-ci ``if`` expressions.
Test pipeline mode (e.g. test-merge.yml):
- Drop steps or groups whose ``source_file_dependencies`` do not match changed files.
@@ -25,6 +26,7 @@ from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
@@ -57,8 +59,8 @@ def _git(*args: str) -> subprocess.CompletedProcess[str]:
)
def resolve_changed_files() -> list[str] | None:
"""Return changed file paths, or None when diff cannot be resolved."""
def resolve_diff_range() -> str | None:
"""Return a git diff range for PR or main builds, or None when unavailable."""
is_pr = os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false" and os.environ.get(
"BUILDKITE_PULL_REQUEST", ""
)
@@ -76,14 +78,22 @@ def resolve_changed_files() -> list[str] | None:
else:
_log(f"cannot resolve PR base {base_branch}; using safe defaults")
return None
diff_range = f"{base_ref}...{commit}"
elif os.environ.get("BUILDKITE_BRANCH", "") == "main":
return f"{base_ref}...{commit}"
if os.environ.get("BUILDKITE_BRANCH", "") == "main":
if _git("rev-parse", "--verify", f"{commit}^").returncode != 0:
_log("main commit has no parent; using safe defaults")
return None
diff_range = f"{commit}^..{commit}"
else:
_log("not PR/main build; using safe defaults")
return f"{commit}^..{commit}"
_log("not PR/main build; using safe defaults")
return None
def resolve_changed_files() -> list[str] | None:
"""Return changed file paths, or None when diff cannot be resolved."""
diff_range = resolve_diff_range()
if diff_range is None:
return None
result = _git("diff", "--name-only", diff_range)
@@ -96,30 +106,207 @@ def resolve_changed_files() -> list[str] | None:
return files
def _is_doc_file(file_path: str) -> bool:
if not file_path:
return False
if file_path.startswith("docs/"):
return True
if file_path.endswith(".md"):
return True
return file_path == "mkdocs.yml"
def is_docs_only_change(changed_files: list[str]) -> bool:
has_any = False
for file_path in changed_files:
if not file_path:
continue
has_any = True
if file_path.startswith("docs/"):
if not _is_doc_file(file_path):
return False
return has_any
def _is_test_python_file(file_path: str) -> bool:
path = Path(file_path)
return path.suffix == ".py" and bool(path.parts) and path.parts[0] == "tests"
_SKIP_MARK_RE = re.compile(r"pytest\.mark\.skip(?:if)?\b|pytest\.skip\s*\(")
_PYTESTMARK_SKIP_RE = re.compile(r"pytest\.mark\.skip\b")
_PYTEST_MARK_ONLY_RE = re.compile(r"pytest\.mark\.\w+")
def _paren_balance(line: str) -> int:
return line.count("(") - line.count(")")
def _is_skip_mark_related_content(line: str) -> bool:
stripped = line.strip()
if not stripped:
return True
if stripped.startswith("#"):
return True
return _SKIP_MARK_RE.search(stripped) is not None
def _is_pytestmark_adjacent_content(line: str) -> bool:
"""Allow pytestmark list refactors that only add skip/skipif alongside existing marks."""
stripped = line.strip().rstrip(",")
if not stripped:
return True
if stripped in {"[", "]"}:
return True
if stripped.startswith("#"):
return True
if stripped.startswith("pytestmark"):
return True
return _PYTEST_MARK_ONLY_RE.search(stripped) is not None
def diff_only_contains_skip_mark_changes(diff_text: str) -> bool:
"""True when diff only edits skip marks or reformats pytestmark to add them."""
pending_depth = 0
saw_change = False
has_skip_mark_edit = False
for raw_line in diff_text.splitlines():
if raw_line.startswith("@@"):
pending_depth = 0
continue
if file_path.endswith(".md"):
if not (raw_line.startswith("+") or raw_line.startswith("-")):
continue
if file_path == "mkdocs.yml":
if raw_line.startswith("+++") or raw_line.startswith("---"):
continue
saw_change = True
content = raw_line[1:]
if pending_depth > 0:
pending_depth += _paren_balance(content)
if pending_depth < 0:
pending_depth = 0
continue
if _is_skip_mark_related_content(content):
has_skip_mark_edit = True
pending_depth = max(0, _paren_balance(content))
continue
if _is_pytestmark_adjacent_content(content):
continue
return False
return saw_change and has_skip_mark_edit
def _git_file_diff(file_path: str, diff_range: str) -> str | None:
result = _git("diff", diff_range, "--", file_path)
if result.returncode != 0:
_log(f"skip-mark-only: git diff failed for {file_path}")
return None
return result.stdout
def _is_new_file_diff(diff_text: str) -> bool:
return "new file mode" in diff_text or "--- /dev/null" in diff_text
def _file_has_module_level_pytest_skip(content: str) -> bool:
"""True when ``pytestmark`` applies unconditional ``pytest.mark.skip`` to the module."""
match = re.search(r"^pytestmark\s*=\s*\[(.*?)^\s*\]", content, re.MULTILINE | re.DOTALL)
if match is not None:
return _PYTESTMARK_SKIP_RE.search(match.group(1)) is not None
match = re.search(r"^pytestmark\s*=\s*pytest\.mark\.skip\b", content, re.MULTILINE)
return match is not None
def _file_content_at_commit(file_path: str, commit: str) -> str | None:
result = _git("show", f"{commit}:{file_path}")
if result.returncode != 0:
_log(f"skip-mark-only: cannot read {file_path} at {commit}")
return None
return result.stdout
def _qualifies_as_skip_mark_test_change(file_path: str, diff_range: str) -> bool:
diff_text = _git_file_diff(file_path, diff_range)
if diff_text is None:
return False
if not diff_text.strip():
_log(f"skip-mark-only: empty diff for {file_path}")
return False
if diff_only_contains_skip_mark_changes(diff_text):
return True
if not _is_new_file_diff(diff_text):
_log(f"skip-mark-only: non-skip changes in {file_path}")
return False
commit = os.environ.get("BUILDKITE_COMMIT", "")
if not commit:
return False
content = _file_content_at_commit(file_path, commit)
if content is None:
return False
if _file_has_module_level_pytest_skip(content):
_log(f"skip-mark-only: new test file with module-level pytest.mark.skip: {file_path}")
return True
_log(f"skip-mark-only: new test file without module-level pytest.mark.skip: {file_path}")
return False
def is_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool:
"""True when every changed file is a tests/ module with only skip-mark edits."""
has_any = False
for file_path in changed_files:
if not file_path:
continue
has_any = True
if not _is_test_python_file(file_path):
return False
if not _qualifies_as_skip_mark_test_change(file_path, diff_range):
return False
return has_any
def is_docs_or_skip_mark_only_change(changed_files: list[str], *, diff_range: str) -> bool:
"""True when each changed file is a doc path or a qualifying skip-mark test change."""
has_any = False
for file_path in changed_files:
if not file_path:
continue
has_any = True
if _is_doc_file(file_path):
continue
if _is_test_python_file(file_path) and _qualifies_as_skip_mark_test_change(file_path, diff_range):
continue
_log(f"docs/skip-mark-only: rejecting {file_path}")
return False
return has_any
def resolve_skip_ci(changed_files: list[str] | None) -> bool:
def resolve_skip_ci(changed_files: list[str] | None, *, diff_range: str | None = None) -> bool:
if changed_files is None:
_log("skip-ci=0 (could not resolve changed files)")
return False
if is_docs_only_change(changed_files):
if diff_range is not None and is_docs_or_skip_mark_only_change(changed_files, diff_range=diff_range):
if is_docs_only_change(changed_files):
_log("docs-only change detected; skip-ci=1")
elif is_skip_mark_only_change(changed_files, diff_range=diff_range):
_log("pytest skip-mark-only change detected; skip-ci=1")
else:
_log("docs + pytest skip-mark-only change detected; skip-ci=1")
return True
if diff_range is None and is_docs_only_change(changed_files):
_log("docs-only change detected; skip-ci=1")
return True
_log("non-doc changes detected; skip-ci=0")
_log("non-doc/non-skip-mark changes detected; skip-ci=0")
return False
@@ -252,10 +439,14 @@ def resolve_pipeline_path(arg: str) -> Path:
def render_pipeline(path: Path) -> str:
text = path.read_text(encoding="utf-8")
diff_range = resolve_diff_range()
changed_files = resolve_changed_files()
if BOOTSTRAP_MARKER in text:
rendered = render_bootstrap_pipeline(text, skip_ci=resolve_skip_ci(changed_files))
rendered = render_bootstrap_pipeline(
text,
skip_ci=resolve_skip_ci(changed_files, diff_range=diff_range),
)
return rendered
doc = yaml.safe_load(text)
+17 -4
View File
@@ -179,17 +179,30 @@ steps:
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m "advanced_model" --run-level "advanced_model"
'
- label: "Voxtral-TTS E2E Test"
# It is the whisper ASR validation step that is slow, not the Voxtral TTS generation.
# whisper ASR runs on CPU.
- label: "Voxtral-TTS E2E Test (Online Serving)"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
timeout 40m bash -c '
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
'
- label: "Voxtral-TTS E2E Test (Offline Inference)"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 30m bash -c '
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
'
# TODO: Bagel test on ROCm is very unstable. @tjtanaa
-14
View File
@@ -185,20 +185,6 @@ steps:
- export VLLM_ALLOW_LONG_MAX_MODEL_LEN="1"
- timeout 30m pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py -m "core_model" --run-level "core_model"
- label: "Voxtral-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
mirror_hardwares: [amdproduction]
grade: Blocking
commands:
- |
timeout 20m bash -c '
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_WORKER_MULTIPROC_METHOD=spawn
pytest -s -v tests/e2e/online_serving/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
pytest -s -v tests/e2e/offline_inference/test_voxtral_tts.py -m "advanced_model" --run-level "advanced_model"
'
- label: "CosyVoice3-TTS E2E Test"
agent_pool: mi325_1
depends_on: amd-build
+1 -1
View File
@@ -61,7 +61,7 @@ steps:
steps:
- label: "Diffusion · Offloader Test"
commands:
- timeout 10m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
- timeout 40m pytest -s -v tests/diffusion/offloader -m "core_model and cuda" --run-level "core_model"
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
+30 -2
View File
@@ -288,9 +288,36 @@ Required updates:
5. `examples/offline_inference/xxx/README.md` — offline example docs
6. `examples/online_serve/xxx/README.md` — online serve docs
### Step 8: Add E2E Tests (Recommended)
### Step 8: Add E2E Tests
Create `tests/e2e/online_serving/test_your_model_expansion.py`.
**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming, Buildkite wiring, and run commands. Also read [l4_functionality_tests.inc.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/test_examples/l4_functionality_tests.inc.md) and [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md).
Classify the model's **CI priority** first:
| Priority | Required test levels | Files & markers |
|----------|---------------------|-----------------|
| **High** (listed in [#1832](https://github.com/vllm-project/vllm-omni/issues/1832) or on the diffusion hot path) | **L1** · **L2** online · **L3** online + offline · **L4** feature + performance | See table below |
| **Medium** (*normal priority* in L4 docs) | **L3** online + offline · **L4** feature only | Fewer L4 parametrized rows |
| **Low** | **L4** feature only | One or two `*_expansion.py` cases |
**Per-level deliverables (diffusion / `pytest.mark.diffusion`):**
| Level | Location | Marker | CI pipeline | Notes |
|-------|----------|--------|-------------|-------|
| **L1** | `tests/diffusion/models/{slug}/`, `tests/diffusion/cache/`, transformer unit tests | `core_model` + `cpu` | `test-ready.yml` | Weight remap, `_sp_plan`, cache enabler registration, shape contracts |
| **L2** | `tests/e2e/online_serving/test_{slug}.py` (and offline if the category is offline-first) | **`core_model` + `advanced_model`** (both on baseline smoke) + `diffusion` + `@hardware_test` / `hardware_marks` | `test-ready.yml` | Default deploy smoke — minimal `num_inference_steps`, single prompt |
| **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` when offline matters | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `diffusion`) | `test-merge.yml` **or** merged into nightly diffusion function job | Real weights, streaming/API paths, LoRA/offload smoke |
| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py` (+ offline expansion if needed) | `full_model` + `diffusion` | `test-nightly.yml` (X2I / X2V / X2A function groups) | Feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); perf → `tests/dfx/perf/tests/test_{model}_vllm_omni.json` |
**L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, minimal steps, single prompt) should carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** function so L2 (`test-ready.yml`) and L3 (`test-merge.yml`) share one smoke test. Heavier deploy variants or API paths in the same file use **`advanced_model` only**. When L3 moves to nightly, migrate those heavier cases into `test_{slug}_expansion.py` with `full_model` and remove the dedicated `test-merge.yml` job (see `test_longcat_image_expansion.py`, `test_qwen_image_expansion.py`).
**L4 design (high priority):** Combine multiple supported features (Cache-DiT, TP, USP, CFG, HSDP, CPU offload, quantization) into **few parametrized** `OmniServerParams` rows so each feature appears in at least one case without exploding GPU jobs. Shard single-GPU vs multi-GPU cases across the nightly X2I/X2V function steps (1-GPU vs `distributed_cuda`).
**L4 design (medium / low):** One or two parametrized rows covering the best quality/perf trade-off; skip perf JSON unless the model is high priority.
**Reference implementations:** `tests/e2e/online_serving/test_qwen_image_edit_expansion.py`, `tests/e2e/online_serving/test_longcat_image_expansion.py`, `tests/e2e/online_serving/test_hunyuan_video_15_expansion.py`.
**Keep model-specific code inside test modules — not `tests/helpers/{slug}.py`:** deploy constants, prompts, sampling dicts, and inline `request_config` / `form_data` belong in each `test_{slug}.py` and `test_{slug}_expansion.py`. Do not add per-model files under `tests/helpers/`; reuse only repo-wide harness (`mark`, `media`, `runtime`, `stage_config`, `assertions`). **L2+ online/offline e2e:** reuse or add `send_*_request` in `tests/helpers/runtime.py` — tests call the handler, not raw `omni.generate` / HTTP. See [vllm-omni-test skill](../vllm-omni-test/SKILL.md) § **Runtime send helpers**.
### Step 9: Add Cache-DiT Acceleration
@@ -568,6 +595,7 @@ See the [Profiling Single-Stage Diffusion](../../../docs/contributing/profiling.
## Reference Files
- [vllm-omni-test skill](../vllm-omni-test/SKILL.md) — L1–L4 markers, naming, Buildkite wiring, run commands
- [Transformer Adaptation](references/transformer-adaptation.md) — porting transformers from diffusers
- [Custom Model Patterns](references/custom-model-patterns.md) — patterns for non-diffusers models
- [Parallelism Patterns](references/parallelism-patterns.md) — TP, SP/USP, CFG parallel, HSDP implementation details
+67 -7
View File
@@ -305,13 +305,72 @@ def build_voice_clone_prompt(ref_audio_path: str, text: str, codec) -> list:
]
```
### Test Case Writing (CI Levels)
**Follow the [vllm-omni-test skill](../vllm-omni-test/SKILL.md)** for markers, file naming (`test_{slug}.py` / `test_{slug}_expansion.py`), Buildkite wiring, and copy-paste run commands. Also read [CI_5levels.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/CI_5levels.md) and [tests_style.md](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/ci/tests_style.md).
Classify the model's **CI priority** first (high / medium / low). High-priority TTS models are typically those on the integration hot path or listed in tracking issues such as [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); medium and low tiers cover the long tail. When unsure, ask the reviewer which tier applies.
| Priority | Required test levels | Files & markers |
|----------|---------------------|-----------------|
| **High** | **L1** unit/logic · **L2** online smoke · **L3** online + offline integration · **L4** feature + performance | See table below |
| **Medium** | **L3** online + offline · **L4** feature only | Skip dedicated L1/L2 unless fixing a logic bug |
| **Low** | **L4** feature only | One or two `*_expansion.py` parametrized cases |
**Per-level deliverables (TTS / `pytest.mark.tts`):**
| Level | Location | Marker | CI pipeline | Notes |
|-------|----------|--------|-------------|-------|
| **L1** | `tests/model_executor/…`, `tests/entrypoints/openai_api/…`, stage-processor tests | `core_model` + `cpu` | `test-ready.yml` | Prompt assembly, async_chunk helpers, `serving_speech` validation — no GPU |
| **L2** | `tests/e2e/online_serving/test_{slug}.py` | **`core_model` + `advanced_model`** (both on baseline smoke) + `tts` + `@hardware_test(...)` | `test-ready.yml` (`ready` label) | Default deploy smoke: single `/v1/audio/speech` or offline `OmniRunner` path |
| **L3** | `tests/e2e/online_serving/test_{slug}.py` **and** `tests/e2e/offline_inference/test_{slug}.py` | Baseline smoke: **`core_model` + `advanced_model`**; heavier cases: `advanced_model` only (+ `tts`) | `test-merge.yml` **or** merged into nightly TTS function job | Streaming, voice clone, batch/queue, async_chunk |
| **L4** | `tests/e2e/online_serving/test_{slug}_expansion.py`, optional offline expansion | `full_model` + `tts` | `test-nightly.yml` (`:full_moon: TTS · Function Test with L4`) | Feature matrix; perf → `tests/dfx/perf/tests/test_tts.json` |
**L2 & L3 online — same file, dual marks on the baseline smoke:** The **first / simplest** case in `test_{slug}.py` (default deploy, single non-streaming `/v1/audio/speech` or equivalent offline path) should carry **both** `@pytest.mark.core_model` **and** `@pytest.mark.advanced_model` on the **same** function so it runs in L2 (`test-ready.yml`, `--run-level core_model`, basic validation) and L3 (`test-merge.yml`, `--run-level advanced_model`, deeper validation) without duplicating the test. In-tree examples: `test_voxcpm2_tts.py::test_text_to_audio_001`, `test_qwen3_tts_customvoice.py::test_text_to_audio_001`.
Heavier scenarios in the same file use **`advanced_model` only** (streaming, extra languages, concurrency, async_chunk, batch). Example: `test_voice_clone_en_streaming_001` → `advanced_model` only. When migrating L3 to nightly, move those heavier cases into `test_{slug}_expansion.py` with `full_model` and drop the dedicated merge job (see `test_ming_tts_expansion.py`, `test_glm_tts_expansion.py`).
```python
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_voice_clone_en_non_streaming_001(omni_server, openai_client) -> None:
openai_client.send_audio_speech_request({...})
```
**L4 consolidation:** Prefer parametrized `OmniServerParams` rows (`default`, `async_chunk`, feature flags) in one expansion module rather than many merge-only files ([#1832](https://github.com/vllm-project/vllm-omni/issues/1832)).
**L4 performance (high-priority models):** Add latency / throughput / stress rows in `tests/dfx/perf/tests/test_tts.json`, or a dedicated `tests/dfx/perf/tests/test_{slug}.json` when the model must not join the shared nightly server matrix before integration lands (see VoxCPM2 / Coqui XTTS pattern). Register the model in `benchmarks/tts/model_configs.yaml` for local `bench_tts.py`. Wire a **separate** `test-nightly.yml` Perf Test step when the JSON is not merged into `test_tts.json` yet.
**Keep model-specific code inside test modules — not `tests/helpers/{slug}.py`:**
- Put `MODEL`, deploy path, vendored `REF_AUDIO_URL`, `get_prompt()`, and inline `request_config` dicts **in each** `test_{slug}.py`, `test_{slug}_expansion.py`, offline `test_{slug}.py`, and L1 `test_{slug}_*.py` as needed.
- **Do not** add `tests/helpers/{slug}.py` (or `tests/helpers/{model_name}.py`) to deduplicate constants or request builders across those files. A little duplication is intentional; follow in-tree references such as `tests/e2e/online_serving/test_glm_tts.py` and `tests/e2e/online_serving/test_cosyvoice3_tts_expansion.py`.
- `tests/helpers/` is for **repo-wide** harness code only (`mark.py`, `media.py`, `runtime.py`, `stage_config.py`, `assertions.py`, `fixtures/`). Import those; do not extend the tree with per-model modules.
**Runtime send helpers (`tests/helpers/runtime.py`) — online and offline e2e:**
| Path | Fixture | Call |
|------|---------|------|
| Online `/v1/*` | `openai_client` | `openai_client.send_*_request(request_config)` |
| Offline inference | `omni_runner_handler` | `omni_runner_handler.send_*_request(request_config)` |
1. **Grep `runtime.py` first** — reuse `send_omni_request`, `send_diffusion_request`, `send_audio_speech_request` (online + offline Qwen-style TTS), `send_single_stage_tts_request` (Coqui XTTS / MOSS-TTS-Nano offline), etc.
2. **No matching helper** → add `send_<feature>_request` (or `send_<route>_http_request` for negative/dfx) in **`runtime.py`** with general `assert_*` bundled inside, **then** call it from the test.
3. **Test file** holds `request_config` dicts only — not `omni.generate`, not `_collect_audio()`, not raw HTTP/SDK.
4. Wire `tests/helpers/runtime.py` into Buildkite `source_file_dependencies` when you add helpers.
See [vllm-omni-test skill](../vllm-omni-test/SKILL.md) § **Runtime send helpers** for full tables and exceptions.
### Deliverables
- Updated `serving_speech.py` with all 5 integration points (single commit)
- Client scripts and server launcher under `examples/online_serving/text_to_speech/<model>/`
- Gradio demo with streaming and voice cloning UI in the same dir
- E2E online serving test (`tests/e2e/online_serving/test_<model>.py`)
- Buildkite CI entry in `.buildkite/test-merge.yml`
- E2E tests per **Test Case Writing (CI Levels)** above (priority tier determines L1–L4 scope)
- Buildkite wired per level: `test-ready.yml` (L1/L2), `test-merge.yml` or nightly function job (L3), `test-nightly.yml` (L4) — see [vllm-omni-test skill](../vllm-omni-test/SKILL.md)
- New section in `examples/online_serving/text_to_speech/README.md` (table row + per-model section). Do **not** create a top-level `examples/online_serving/<model>/` dir or a per-model `README.md` inside `text_to_speech/<model>/`.
### E2E test pitfalls to avoid
@@ -326,9 +385,9 @@ def build_voice_clone_prompt(ref_audio_path: str, text: str, codec) -> list:
- **Use the harness readiness gate.** The fixture waits for HTTP 200 on
`/health`; don't add `time.sleep` in tests. If warmup is incomplete, make
`/health` return non-200 until you're actually ready.
- **Mark with `@pytest.mark.core_model` + `hardware_test(res={"cuda": "H100"})`**
so the test lands in `test-ready.yml` (triggered by the `ready` label) rather
than only nightly.
- **Mark tests per the CI Levels table** — baseline smoke: `core_model` + `advanced_model`; heavier cases: `advanced_model` only; L4 expansion: `full_model`
- **No per-model helper modules** — do not create `tests/helpers/{slug}.py`; keep constants and `request_config` payloads in the test file
- **Online and offline e2e go through `runtime.py`** — `openai_client.send_audio_speech_request` (online); `omni_runner_handler.send_audio_speech_request` (Qwen-style offline) or `send_single_stage_tts_request` (single-stage offline). Add a new `send_*_request` in `runtime.py` when none fits; do not embed `omni.generate` or HTTP in tests
## Phase 4: Async Chunk (Streaming)
@@ -461,8 +520,8 @@ Use this checklist when integrating a new TTS model:
- [ ] Voice cloning works (if supported)
- [ ] All response formats work (wav, mp3, flac, pcm)
- [ ] Client scripts and server launcher created
- [ ] E2E online serving test written (`tests/e2e/online_serving/test_<model>.py`)
- [ ] Buildkite CI entry added to `.buildkite/test-merge.yml`
- [ ] E2E tests added per model priority tier (see **Test Case Writing (CI Levels)**)
- [ ] Buildkite entries match level: `test-ready.yml` / `test-merge.yml` or nightly TTS job / `test-nightly.yml`
- [ ] Gradio demo working
- [ ] Documentation added (offline + online docs, nav, supported models)
@@ -500,5 +559,6 @@ Project docs and adjacent skills:
- [TTS audio skill](../vllm-omni-audio-tts/SKILL.md) — supported models and usage
- [Fish Speech integration](../vllm-omni-audio-tts/references/fish-speech.md) — complete example of Phases 1–3
- [Qwen3-TTS reference](../vllm-omni-audio-tts/references/qwen-tts.md) — complete example of all 5 phases
- [vllm-omni-test skill](../vllm-omni-test/SKILL.md) — L1–L4 markers, naming, Buildkite wiring, run commands
- [Adding a TTS model (developer guide)](https://github.com/vllm-project/vllm-omni/blob/main/docs/contributing/model/adding_tts_model.md)
- `plan/voxcpm2_native_ar_design.md` — VoxCPM2's vLLM-native AR + side-computation pattern (distinct from the generator-based single-stage described above)
+6
View File
@@ -30,6 +30,12 @@ include:
- `precheck-pr`: self-check a branch before creating a PR — validates PR title
format, catches dead code, verifies accuracy/perf claims, and confirms merge
readiness
- `vllm-omni-test`: guides generation and execution of CI-aligned tests (L1–L4),
pytest marker selection (`core_model` / `advanced_model` / `full_model`,
`omni` / `tts` / `diffusion`), Buildkite wiring (`test-ready.yml`,
`test-merge.yml`, `test-nightly.yml`, `test-weekly.yml`), and copy-paste
local plus CI-like `pytest` commands; see `references/test-routing.md` for
level-to-command mapping
- `review-pr`: provides a structured workflow for reviewing pull requests
- `vllm-omni-npu-model-runner-upgrade`: upgrades NPU model runners to align with the
latest vllm-ascend NPUModelRunner
+890
View File
@@ -0,0 +1,890 @@
---
name: vllm-omni-test
description: Generate and run tests for vllm-project/vllm-omni with CI-aligned levels and markers; wire new tests into Buildkite (test-ready.yml for L1/L2, test-merge.yml for L3, test-nightly.yml for L4). On completion, always provide copy-paste local and CI-like pytest commands plus prerequisites. Use when creating regression tests, adding L1-L4 coverage, selecting pytest markers, or validating fixes from issues/PRs.
---
# vLLM-Omni Test Generator & Runner
## Purpose
Use this skill to generate minimal, stable test cases and run them with the correct marker/level strategy for [vllm-project/vllm-omni](https://github.com/vllm-project/vllm-omni).
**Link convention:** Paths such as `.buildkite/` and `docs/contributing/` live at the **vllm-omni repo root**. Markdown links use repo-relative paths from this skill file (e.g. `../../../.buildkite/test-ready.yml`, `../../../docs/contributing/ci/CI_5levels.md`).
Default priorities:
1. Reproducible regression coverage for bug fixes
2. Correct test level and marker selection
3. Low flake, low dependency tests first
4. CI-compatible run commands
5. **Actionable run commands for the human**: whenever you add or change tests, always finish with **copy-paste-ready** `pytest` lines (local: single file and/or single test; CI-like: markers + `--run-level`), plus short **prerequisites** (GPU tier, HF cache, optional `model_prefix`). Do not assume the reader will infer commands from `test-routing.md` alone.
## Inputs
- Issue/PR link and summary
- Changed files or suspected code path
- Whether the user wants local quick validation or CI-equivalent validation
- Hardware constraints (CPU only / CUDA / ROCm / NPU)
## Workflow
### Step 1: Classify Test Goal
- **Bugfix regression**: start from a minimal failing scenario and add assertions that prevent recurrence. Before writing tests, output **`required` / `recommended` / `not_needed`**: **`required`** — stable logic/contract bug that should have been caught; **`recommended`** — environment-sensitive but a small regression still helps; **`not_needed`** — one-off external/config failure or existing tests already cover the path. Prefer the narrowest stable L1 (CPU) case; escalate to L2/L3 only when the bug needs real weights or serving.
- **Feature coverage**: verify new behavior and one negative/boundary case.
- **Perf/benchmark claim**: require benchmark-oriented tests and explicit metrics.
### Step 2: Select Test Level
- **L1**: unit/logic, deterministic, CPU-friendly, fastest feedback.
- **L2**: basic e2e/integration and platform-dependent checks.
- **L3/L4**: advanced model/integration/perf validation.
Use [references/test-routing.md](references/test-routing.md) for level-to-marker and command mapping.
### Step 3: Pick Markers
Always attach markers deliberately:
- **Level**: `core_model` (L1/L2) and/or `advanced_model` (L3) and/or `full_model` (L4 nightly)
- **Model type** (required on model-centric e2e — pick exactly one):
- `omni` — end-to-end multimodal LLM pipelines (thinker/talker/stages; Qwen-Omni family)
- `tts` — speech synthesis / TTS-only models (`/v1/audio/speech`, voice clone, etc.)
- `diffusion` — generative diffusion models (image / audio / text / video from noise)
- **Cross-cutting area** (when relevant): `parallel`, `cache`, `example`, `benchmark`
- **Hardware**: `cpu`, `gpu`, `cuda`, `rocm`, `npu`, `L4`, `H100`, `distributed_cuda`, …
- Optional: `slow`, distributed markers when multi-card is required
**Baseline smoke (L2 + L3):** The simplest e2e case per model — default deploy, minimal request — should usually carry **both** `@pytest.mark.core_model` and `@pytest.mark.advanced_model` on the **same** test function so `test-ready.yml` and `test-merge.yml` share one test. `send_*_request` picks validation depth from `--run-level`. References: `test_voxcpm2_tts.py::test_text_to_audio_001`, `test_qwen3_tts_customvoice.py::test_text_to_audio_001`. Heavier scenarios use **`advanced_model` only**; L4 expansion uses **`full_model`**.
For hardware-aware tests, prefer `@hardware_test(...)` or `hardware_marks(...)` in `tests/helpers/mark.py`.
**Diffusion nightly split** (`test-nightly.yml`): all diffusion tests use `pytest.mark.diffusion`, but CI groups them by **output modality**, not by separate pytest markers:
| Nightly group | Scope | Typical models / paths |
|---------------|--------|-------------------------|
| **Diffusion X2I(&A&T) Model Test** | x2**i** (image), x2**a** (audio), x2**t** (text) and other **non-video** diffusion | Qwen-Image*, BAGEL, FLUX, SD3, Z-Image, LongCat, DreamZero, … — `test_*_expansion.py` under X2I shards; L4 sweep uses `-k "not test_wan and not test_bagel_expansion and not hunyuan"` for L4 |
| **Diffusion X2V Model Test** | x2**v** (video) only | Wan2.2, HunyuanVideo 1.5, Wan VACE, … — e.g. `test_wan22_expansion.py`, `test_hunyuan_video_15_expansion.py` |
Wire new **video** diffusion expansion tests into the **X2V** group; wire **image/audio/text** diffusion expansion tests into **X2I(&A&T)**. Do not place x2v modules in X2I shards (see comment in `test-nightly.yml` above the X2I group).
### Naming: generated test module files (L2–L4, model-centric e2e)
When adding **new** pytest modules whose primary scope is a **specific model** (typical under `tests/e2e/offline_inference/` or `tests/e2e/online_serving/`), use this filename pattern:
| Level | Filename pattern | Example (model `Qwen/Qwen2.5-Omni-7B`) |
|-------|------------------|----------------------------------------|
| **L2**, **L3** | `test_{lowercase_model_slug}.py` | `test_qwen2_5_omni.py` |
| **L4** | `test_{lowercase_model_slug}_expansion.py` | `test_qwen2_5_omni_expansion.py` |
**Slug rules for `{lowercase_model_slug}`:**
1. Start from the HuggingFace-style id (e.g. `Qwen/Qwen2.5-Omni-7B`), but **do not** put the org into the filename: use the **repo segment only** (`Qwen2.5-Omni-7B`), not `Qwen_...` / `qwen_qwen2_5_...`.
2. **Lowercase**; replace `.`, `-`, and whitespace with a single `_` (e.g. `Qwen2.5-Omni-7B` → `qwen2_5_omni`). **Omit** trailing size tokens such as `7b` / `30b` in the basename when a single file covers that model line in the directory (matches `test_qwen2_5_omni.py` in-tree).
3. If two checkpoints in the same folder need separate modules, add a **minimal** disambiguator (e.g. `_7b` vs `_3b`) only then.
4. **L1** unit tests are **not** bound to this pattern; use `tests/<area>/test_<feature>.py` as today.
Routing tables and commands: [references/test-routing.md](references/test-routing.md) § *Model-centric e2e filename convention*.
Existing references: `tests/e2e/offline_inference/test_qwen2_5_omni.py` (L2-style omni), `tests/e2e/offline_inference/test_qwen3_5_9b.py` (L2-style omni, single-stage VL), `tests/e2e/online_serving/test_qwen3_omni_expansion.py` (L4-style omni), `tests/e2e/online_serving/test_qwen_image_edit_expansion.py` / `test_qwen_image_expansion.py` (L4-style diffusion).
### Step 4: Generate Test Case Skeleton
**1. Pick the functional scenario** (then choose directory, fixtures, and markers):
| Scenario | Typical location | Fixtures / runner pattern | Baseline markers & level |
|----------|------------------|---------------------------|---------------------------|
| **Offline inference e2e** | `tests/e2e/offline_inference/` | **Module (default):** `omni_runner` + `omni_runner_handler`. **Function (isolation only):** `omni_runner_function` + `omni_runner_handler_function`. Diffusion/TTS may use `Omni(...).generate` directly | L2: `core_model` + **one of** `omni` / `tts` / `diffusion`; `@hardware_test(...)` when GPU/NPU is required |
| **Online serving e2e** | `tests/e2e/online_serving/` | **Module (default):** `omni_server` + `openai_client`. **Function (isolation only):** `omni_server_function` + `openai_client_function`. Clients: `send_omni_request` (omni), `send_audio_speech_request` (tts), `send_diffusion_request` / `send_video_diffusion_request` / `send_images_generations_request` (diffusion) | Baseline smoke: **`core_model` + `advanced_model`**; heavier paths: `advanced_model` only; L4 expansion: `full_model` |
| **Documentation / runnable examples** | `tests/examples/offline_inference/`, `tests/examples/online_serving/` | **Offline docs (preferred):** extract Python/Bash blocks from the doc README (e.g. `ReadmeSnippet.extract_readme_snippets`), `pytest.mark.parametrize` each snippet, run via `example_runner.run` with a stable `output_subfolder`. **Online docs:** copy client/request scripts into dedicated tests and keep them in sync with the doc page. | Usually **L4**: `advanced_model`, often `example` plus hardware marks matching the nightly docs-example job (see `.buildkite/test-nightly.yml`). Full conventions: [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) (introduced in [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910): naming, output directory layout, skip rules, avoid trimming `num_inference_steps` without a strong CI reason). |
| **Performance / benchmark** | `tests/dfx/perf/tests/*.json` + `run_*_benchmark.py` | JSON or script-driven server + load config; assert explicit metrics / baselines | L4 Perf: `full_model` + `benchmark`; wire `test-nightly.yml` Perf steps |
| **Invalid parameter / negative HTTP validation** | `tests/dfx/reliability/invalid_param_test/` | Live `omni_server` + low-level `send_*_http_request` with `err_code` / `err_message` | `pytest.mark.slow` + `omni` / `tts` / `diffusion` + `@hardware_marks` (`H100` or `L4`); CI in **`test-weekly.yml`** (not ready/merge/nightly) |
**If the user’s test plan includes invalid parameter validation / invalid params / negative HTTP / 400 validation:** do **not** add those `test_*` functions to `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move or author them** under `tests/dfx/reliability/invalid_param_test/` in the **endpoint-matching script** (see **Invalid parameter validation** below). Success-path e2e and invalid-param dfx tests must stay in separate modules.
**1b. Model type — `omni` vs `tts` vs `diffusion`**
After choosing offline/online/docs/perf, classify the **product under test** and attach **exactly one** model-type marker. All three can live under the same `tests/e2e/` trees; conventions diverge:
| Dimension | **Omni** (`pytest.mark.omni`) | **TTS** (`pytest.mark.tts`) | **Diffusion** (`pytest.mark.diffusion`) |
|-----------|------------------------------|----------------------------|----------------------------------------|
| **What it is** | Multimodal LLM pipeline (thinker/talker/stages; text + vision + audio I/O) | Speech synthesis / voice models | Generative diffusion (noise → image, audio, text, or video) |
| **Examples** | Qwen2.5-Omni, Qwen3-Omni | Qwen3-TTS, VoxCPM2, Higgs-Audio, Step-Audio2 | Qwen-Image, BAGEL, Wan2.2, HunyuanVideo |
| **Offline runner** | `omni_runner` + `omni_runner_handler`, `generate_multimodal` | `Omni(...).generate` or stage YAML + TTS params | `Omni(...).generate` + `OmniDiffusionSamplingParams` |
| **Online client** | `openai_client.send_omni_request` (chat completions, modalities) | `openai_client.send_audio_speech_request` (`/v1/audio/speech`) | `send_diffusion_request` (chat/T2I), `send_video_diffusion_request` (`/v1/videos`, X2V), or `send_images_generations_http_request` / `send_images_edits_http_request` (DALL-E routes) |
| **Typical assertions** | Stage outputs, text/audio keywords via `OmniRunnerHandler` / response handler | WAV bytes, stream chunks, speech endpoint contract | Image/video dimensions, `final_output_type`, `assert_diffusion_response` |
| **Stage / deploy YAML** | Per-model omni stage configs (`ci/qwen3_omni_moe.yaml`, …) | `qwen3_tts.yaml`, `voxcpm2.yaml`, … | Often default serve; parallel/offload YAML for heavy DiT |
| **Nightly group** (`test-nightly.yml`) | **Omni Model Test** — `-m "full_model and omni"` | **TTS Model Test** — `-m "full_model and tts"` | **Diffusion X2I(&A&T)** *or* **Diffusion X2V** (see Step 3 table; same `diffusion` marker, different YAML group / file shard) |
| **L4 pressure** | Expansion per modality/model as needed | Expansion + accuracy/perf in TTS group | X2I: merge feature combos per [#1832](https://github.com/vllm-project/vllm-omni/issues/1832); X2V: separate nightly group |
Do **not** mix fixtures across types (e.g. do not use `omni_runner` layout for a pure diffusion or TTS model without mirroring an in-tree test in that family).
**Diffusion only — X2I(&A&T) vs X2V (nightly routing, not extra markers):**
- **X2I(&A&T)**: image / audio / text generation — Qwen-Image*, FLUX, SD3, Z-Image, BAGEL (expansion), LongCat, audio diffusion, etc.
- **X2V**: video generation only — Wan2.2, HunyuanVideo 1.5, Wan VACE, LTX video similarity paths.
When adding a new `test_*_expansion.py`, place it in the matching **nightly group** step (explicit file list in `test-nightly.yml`), not only by marker expression.
**2. Use the narrowest deterministic skeleton for the scenario**
*L1 unit / logic (CPU-first):*
**Mocking rule (L1 only):** use **pytest** integration — **`mocker`** (`pytest-mock`) or **`monkeypatch`** (built-in). **Do not** import or call **`unittest.mock`** (`patch`, `MagicMock`, `@patch`, `with patch(...)`, etc.) in L1 tests; patches must auto-revert with the test lifecycle.
```python
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_<scenario_name>(mocker):
# Prefer mocker.patch / mocker.spy / mocker.Mock — not unittest.mock.patch
fake_fn = mocker.patch("vllm_omni.some.module.expensive_call", return_value=...)
# Act
# Assert
fake_fn.assert_called_once()
def test_<env_or_attr>(monkeypatch):
# For simple env / attribute substitution without a Mock object
monkeypatch.setenv("SOME_FLAG", "1")
monkeypatch.setattr("vllm_omni.some.module.CONST", 42)
...
```
**Avoid in L1:**
```python
# BAD — do not use in L1 unit tests
from unittest.mock import patch, MagicMock
@patch("vllm_omni.some.module.fn")
def test_bad(mock_fn): ...
def test_also_bad():
with patch("...") as m: ...
```
See **L1 unit test constraints (mocking)** below for the full do/don't list.
*Offline multimodal e2e — **Omni** (representative):*
```python
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_<scenario>(omni_runner, omni_runner_handler) -> None:
request_config = {"prompts": ..., "modalities": [...]} # optional: images, videos, audios
omni_runner_handler.send_omni_request(request_config)
```
*Offline generative e2e — **Diffusion** (representative):*
```python
@pytest.mark.core_model
@pytest.mark.diffusion
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_image_001(omni_runner_handler) -> None:
omni_runner_handler.send_diffusion_request({"prompt": "...", "extra_body": {"num_inference_steps": 4, ...}})
```
*Offline TTS e2e — **Qwen3-TTS** (two-stage; representative):*
```python
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_runner, omni_runner_handler) -> None:
omni_runner_handler.send_audio_speech_request({
"input": "...",
"task_type": "Base",
"ref_audio": REF_AUDIO_URL,
"ref_text": REF_TEXT,
})
```
*Offline TTS e2e — **single-stage** (Coqui XTTS, MOSS-TTS-Nano; representative):*
```python
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_runner", tts_server_params, indirect=True)
def test_voice_clone_001(omni_runner_handler) -> None:
omni_runner_handler.send_single_stage_tts_request({
"input": "...",
"language": "en",
"prompt_audio_path": REF_AUDIO_PATH,
"response_format": "wav",
"run_level": "advanced_model",
})
```
*Online serving e2e — **Omni** (representative):*
```python
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(...)
@pytest.mark.parametrize("omni_server", test_params, indirect=True)
def test_text_to_text_001(omni_server, openai_client) -> None:
request_config = {"model": omni_server.model, "messages": ..., "modalities": ["text"]}
openai_client.send_omni_request(request_config)
```
*Online serving e2e — **TTS** (representative):*
```python
@pytest.mark.core_model
@pytest.mark.advanced_model
@pytest.mark.tts
@hardware_test(...)
@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True)
def test_text_to_audio_001(omni_server, openai_client) -> None:
request_config = {"model": omni_server.model, "input": "...", "response_format": "wav", ...}
openai_client.send_audio_speech_request(request_config)
```
*Online serving e2e — **Diffusion** X2I (representative):*
```python
@pytest.mark.core_model
@pytest.mark.diffusion
@pytest.mark.parametrize("omni_server", _get_default_case(MODEL), indirect=True)
def test_text_to_image_001(omni_server, openai_client) -> None:
openai_client.send_diffusion_request({...}) # chat completions + extra_body
```
*Online serving e2e — **Diffusion** X2V (representative):*
```python
@pytest.mark.core_model
@pytest.mark.diffusion
def test_text_to_video_001(omni_server, openai_client) -> None:
openai_client.send_video_diffusion_request({"model": ..., "form_data": {...}}) # /v1/videos
```
*Documentation example tests:* follow the **Preferred Test Strategy** in [l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md): dynamic extraction for offline READMEs; explicit copied client code for online pages until extraction is justified; use the documented **naming**, **output directory** (page folder + case id), and **skipping** rules (e.g. Gradio-only scripts).
*Performance tests:* add or extend entries under `tests/perf/` (and JSON configs where the project uses them), with **explicit baselines** and the same marker/run-level pairing as the CI step that will execute them.
**3. Cross-cutting rules**
- Reuse existing fixtures for the chosen scenario; do not mix “online client” assumptions into offline `OmniRunner` tests without a clear reason.
- Avoid external network dependency in assertions unless the scenario is explicitly “online serving” or doc examples that require a model hub (then align with CI secrets/cache).
- Keep **one test function = one intent** (one modality combo, one endpoint contract, or one acceleration combo).
- **E2E test function layout**: **one case → one `test_<scenario>` function** with a name that states what is validated (endpoint, size/`n`, server flag, or route). **Do not** merge multiple cases into a single test that branches on `request.node.callspec.id`, `param.id`, or `if case_id == ...`. Use `@pytest.mark.parametrize("omni_server", [...], indirect=True)` per function (usually **one** `OmniServerParams` per test). A loop inside one test is OK only when it serves **that function’s single intent** (e.g. three standard sizes in `test_*_sizes_256_512_1024`).
- **Runtime fixture scope** (`tests/helpers/fixtures/runtime.py`): default **`omni_server` / `omni_runner`** (module) + matching client/handler; use **`omni_server_function` / `omni_runner_function`** only when each `test_*` must start a fresh instance (see below).
- **L1 mocks**: never `unittest.mock`; use `mocker` or `monkeypatch` only (see below).
- **API calls (L2+ e2e, online and offline)**: **reuse** `send_*_request` in `tests/helpers/runtime.py` when it exists; **otherwise add** the helper in `runtime.py` first, then call it from the test. General `assert_*` inside `send_*_request`; special `assert_*` only in the test. See **Runtime send helpers** below — do not call `omni.generate`, raw HTTP, or SDK clients from `test_*.py`.
- **Response assertions**: reusable checks on API bodies / decoded media belong in **`tests/helpers/assertions.py`** — not as private `_assert_*` helpers inside `test_*.py` (see below).
- **Model-specific payloads stay in test modules** — per-model `MODEL`, deploy path, `REF_AUDIO_URL`, `get_prompt()`, `_build_request()`, and inline `request_config` dicts live in `test_{slug}.py` / `test_{slug}_expansion.py` (and offline/L1 siblings). **Do not** create `tests/helpers/{slug}.py` to deduplicate them; a little copy across files is preferred (see `test_glm_tts.py`, `test_cosyvoice3_tts_expansion.py`). `tests/helpers/` is repo-wide harness only (`mark`, `media`, `runtime`, `stage_config`, `assertions`, `fixtures/`).
#### L1 unit test constraints (mocking)
L1 tests (`core_model and cpu`, under `tests/diffusion/`, `tests/engine/`, `tests/model_executor/`, etc.) must follow the repo’s **pytest-mock** convention (`pytest-mock>=3.10.0` in `pyproject.toml` `[project.optional-dependencies] dev`).
| Do | Don't |
|----|-------|
| `def test_foo(mocker):` + `mocker.patch(...)`, `mocker.spy(...)`, `mocker.Mock()`, `mocker.MagicMock()`, `mocker.AsyncMock()` | `from unittest.mock import patch, MagicMock, Mock, AsyncMock` |
| `def test_bar(monkeypatch):` + `monkeypatch.setattr` / `setenv` / `delenv` / `setitem` | `@patch(...)` decorator |
| Let `mocker` auto-stop patches after the test | `with patch(...):` / `patch.object(...)` context managers |
| Mirror neighboring L1 tests in the same directory | `unittest.mock.create_autospec` unless an existing file already documents an exception |
**Rationale:** `mocker` ties patch lifecycle to pytest fixtures (no leaked patches across tests). `unittest.mock` decorators/context managers are easy to compose incorrectly with parametrized or async tests and are inconsistent with in-tree L1 style.
**Minimal patterns:**
```python
def test_returns_cached_config(mocker):
loader = mocker.patch(
"vllm_omni.foo.load_yaml",
return_value={"stages": []},
)
result = get_deploy_config("ci/foo.yaml")
assert result["stages"] == []
loader.assert_called_once_with("ci/foo.yaml")
def test_skips_when_env_unset(monkeypatch):
monkeypatch.delenv("VLLM_OMNI_FEATURE", raising=False)
assert should_enable_feature() is False
```
E2E levels (L2+) generally **avoid mocks**; if a rare L2 stub is unavoidable, still prefer `mocker` over `unittest.mock` for consistency.
#### Runtime fixtures — scope (`tests/helpers/fixtures/runtime.py`)
vLLM-Omni e2e tests start a real **OmniServer** (online) or **OmniRunner** (offline). Pick scope by **how often the process must be recreated**, not by test level alone.
| Scope | Online fixtures | Offline fixtures | When to use |
|-------|-----------------|------------------|-------------|
| **Module** (default) | `omni_server` → `openai_client` | `omni_runner` → `omni_runner_handler` | **Default for L2/L3/L4 expansion** — amortize model/server init across `test_*` in the same module. Same `OmniServerParams` / runner config can be reused by multiple tests. |
| **Function** | `omni_server_function` → `openai_client_function` | `omni_runner_function` → `omni_runner_handler_function` | **Only when required** — each `test_*` must get a **clean** server/runner (no shared engine/GPU state). Typical: `tests/dfx/reliability/`, sleep/wakeup, crash/restart, tests that mutate global server state. |
**Rules:**
1. **Default to module scope** (`omni_server` / `omni_runner`) unless the scenario **explicitly** needs a fresh instance per test function.
2. **Indirect parametrize name must match the fixture name**: `@pytest.mark.parametrize("omni_server", ...)` with `omni_server` + `openai_client`; `@pytest.mark.parametrize("omni_server_function", ...)` with `omni_server_function` + `openai_client_function`. Do not mix module fixture with function client (or vice versa).
3. Different `OmniServerParams` per `test_*` (e.g. default vs `--enable-cpu-offload`) is still OK with **module** `omni_server` — pytest parametrizes per test node; only switch to `_function` when isolation between tests matters, not merely because `server_args` differ.
4. One `test_*` with **many** server configs (expansion matrix) → single function + `@pytest.mark.parametrize("omni_server", [...], indirect=True)` + module `omni_server` (see in-tree `test_qwen_image_expansion.py`).
```python
# Default — module-scoped server (L4 expansion)
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=H100)], indirect=True)
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
openai_client.send_images_generations_request({...})
# Function-scoped — reliability / per-test clean state only
@pytest.mark.parametrize(
"omni_server_function",
[pytest.param(OmniServerParams(model=MODEL), marks=H100)],
indirect=True,
)
def test_foo_sleep_wakeup_cycle(omni_server_function, openai_client_function) -> None:
openai_client_function.send_omni_sleep_http_request({...})
openai_client_function.send_omni_wakeup_http_request({...})
```
#### Runtime send helpers — online **and** offline (`tests/helpers/runtime.py`)
**L2+ e2e (online serving and offline inference) must call APIs through `tests/helpers/runtime.py`.** Fixtures live in `tests/helpers/fixtures/runtime.py`; **send/assert implementation** lives in `tests/helpers/runtime.py` + `tests/helpers/assertions.py`.
| Principle | Action |
|-----------|--------|
| **Reuse first** | Grep `runtime.py` for an existing **`send_*_request`** on `OpenAIClientHandler` (online) or `OmniRunnerHandler` (offline) that matches the endpoint / pipeline shape. |
| **Extend when close** | If an existing `send_*_request` almost fits (e.g. missing one optional field), extend it in `runtime.py` — do not fork logic in the test file. |
| **Add when missing** | No suitable helper → add **`send_<feature>_request`** (high-level: call + general `assert_*`) or **`send_<route>_<verb>_http_request`** (low-level HTTP for negative/dfx) in **`runtime.py` first**, then call it from tests. |
| **Test module owns payload only** | In `test_*.py`: `MODEL`, deploy path, vendored media, `get_prompt()`, and inline **`request_config` dicts** only. **No** `omni.generate(...)`, raw `requests.post`, OpenAI SDK calls, or `_collect_audio()` / `_process_output()` in e2e tests. |
**Online** (`openai_client` from `omni_server`): `OpenAIClientHandler.send_*_request`.
**Offline** (`omni_runner_handler` from `omni_runner`): `OmniRunnerHandler.send_*_request`.
| Do | Don't |
|----|-------|
| Online: `openai_client.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_video_diffusion_request`, `send_images_generations_request`, … | `requests.post(f"{base_url}/v1/...", json=…)` or `client.chat.completions.create(...)` inside a test |
| Offline: `omni_runner_handler.send_omni_request`, `send_diffusion_request`, `send_audio_speech_request`, `send_single_stage_tts_request`, `send_single_stage_tts_batch_request`, … | `omni_runner.omni.generate(...)` + hand-rolled tensor/WAV extraction in `test_*.py` |
| Add missing `send_*` to **`runtime.py`** first; bundle general `assert_*` inside the send helper | A one-off `def _post_*` / `def _collect_audio` at the bottom of a test module |
| Mirror naming/style of neighboring `send_*` (docstring, `request_config` dict, optional `run_level`, `err_code` / `err_message` for negative cases) | Different parameter shapes per test file for the same endpoint |
**Workflow when generating online/offline e2e tests:**
1. Decide whether the needed check is **general** (every success call of this `send_*`) or **special** (one case / one parameter combo only). See **General vs special assert placement** below.
2. **Search `runtime.py`** for a matching **`send_*_request`**; if missing, add it (with general `assert_*` inside) before writing the test body.
3. In the test module: build `request_config` → call **`send_*_request` only** for the general contract; call **`assert_*` in the test file only** for special, case-specific checks (import from `assertions.py`).
4. Reserve **low-level** `send_*_http_request` for negative/dfx tests (`err_code` / `err_message`) — not for ordinary L2+ success-path e2e.
5. When wiring Buildkite `source_file_dependencies`, include **`tests/helpers/runtime.py`** and/or **`tests/helpers/assertions.py`** when new helpers were added.
**Exceptions (document in the test docstring why):** models whose offline prompt path cannot go through existing handlers yet (e.g. `test_higgs_audio_v2.py`, `test_voxtral_tts.py` with custom tokenizer compose) may call `omni.generate` directly until a `send_*_request` is added to `runtime.py` — treat as **debt**, not the default for new TTS/diffusion/omni e2e.
**General vs special assert placement:**
| Kind | Definition | Where it lives | Test file calls |
|------|------------|----------------|-----------------|
| **General** | Default success contract for this endpoint/client method — e.g. HTTP 200, `data[]` shape, `n` count, `size` dimensions, decodable image/audio, bundled omni/diffusion fields | Implement in `assertions.py`, invoke **inside** `send_*_request` in `runtime.py` (after low-level send / SDK call, when `err_code` is not set) | **`send_*_request` only** — do **not** repeat the same `assert_*` |
| **Special** | Extra check tied to **this test case only** — e.g. seed byte-identical replay, accuracy/CLIP threshold, perf ceiling, model-specific optional field | Add or reuse `assert_*` in `assertions.py` (never inline in test) | `send_*_request` **then** `assert_<special>(...)` **once** for that case |
Changing `request_config` fields (`size`, `n`, `seed`, server flags) is **not** special validation — the general `assert_*` should read those from `request_config` inside `send_*`.
**`send_*` ↔ `assert_*` pairing (online `OpenAIClientHandler`):**
| High-level `send_*` (prefer in L2+ e2e) | Assert already invoked inside `runtime.py` | Low-level HTTP-only sibling |
|----------------------------------------|--------------------------------------------|-----------------------------|
| `send_omni_request` | `assert_omni_response` | `send_chat_completions_http_request` → `assert_http_error` only |
| `send_diffusion_request` | `assert_diffusion_response` | — |
| `send_audio_speech_request` | `assert_audio_speech_response` | `send_audio_speech_http_request` → `assert_http_error` only |
| `send_video_diffusion_request` | `assert_diffusion_response` | `send_videos_*_http_request` → `assert_http_error` only |
| `send_images_generations_request` *(add when needed)* | **`assert_images_generations_response`** *(general — inside send)* | `send_images_generations_http_request` → `assert_http_error` only |
| `send_images_edits_request` *(add when needed)* | **`assert_images_edits_response`** *(general — inside send)* | `send_images_edits_http_request` → `assert_http_error` only |
**Rule:** Tests call high-level `send_*_request` for the general contract. **Never** call the same bundled `assert_*` again. Call an extra `assert_*` in the test **only** for special, case-specific validation.
**Common `OpenAIClientHandler` entry points** (non-exhaustive — grep `runtime.py` before adding):
| Area | High-level (SDK + assert) | Low-level HTTP (`*_http_request`) |
|------|---------------------------|-----------------------------------|
| Omni chat | `send_omni_request` | `send_chat_completions_http_request` |
| Diffusion T2I (chat route) | `send_diffusion_request` | — |
| Diffusion X2V | `send_video_diffusion_request` | `send_videos_create_http_request`, `send_video_content_http_request`, … |
| DALL-E T2I / edit | `send_images_generations_request`, `send_images_edits_request` | `send_images_generations_http_request`, `send_images_edits_http_request` |
| TTS (online) | `send_audio_speech_request` | `send_audio_speech_http_request`, `send_audio_generate_http_request`, … |
| Ops / meta | — | `send_health_http_request`, `send_models_http_request`, `send_omni_sleep_http_request`, … |
**Common `OmniRunnerHandler` entry points** (offline — grep `runtime.py` before adding):
| Area | High-level (`send_*_request` + assert) | Notes |
|------|----------------------------------------|-------|
| Omni multimodal | `send_omni_request` | `generate_multimodal` path |
| Diffusion offline | `send_diffusion_request` | chat-route / `OmniTextPrompt` offline |
| Qwen-style TTS | `send_audio_speech_request` | two-stage, `generate_multimodal` + `mm_processor_kwargs` |
| Single-stage TTS (Coqui XTTS, MOSS-TTS-Nano, …) | `send_single_stage_tts_request`, `send_single_stage_tts_batch_request` | `prompt` + `additional_information` + `omni.generate` — **do not** duplicate in tests |
| New model family | **Add** `send_<family>_request` here first | Then call from `tests/e2e/offline_inference/test_*.py` |
L1 tests under `tests/entrypoints/` may use **FastAPI `TestClient`** or direct handler calls with mocks; they do **not** need `OpenAIClientHandler` / `OmniRunnerHandler`. **L2+ online and offline e2e** must use `runtime.py` `send_*` helpers.
#### Invalid parameter validation (`tests/dfx/reliability/invalid_param_test/`)
When the user asks for **invalid parameter validation**, **invalid request bodies**, **HTTP 4xx contract tests**, or any case that sends **malformed / out-of-range / mismatched** API payloads against a **live** server:
1. **Do not** place these in `tests/e2e/online_serving/` or `*_expansion.py`. If already drafted there, **move** the `test_*` into the correct `invalid_param_test` script and delete the duplicate from e2e.
2. **Pick the script by HTTP route** (extend an existing file; add a new `test_invalid_<area>.py` only when no in-tree script covers that route family):
| API / area | Script |
|------------|--------|
| Omni chat completions, WebSocket video/realtime paths | `test_invalid_omni_chat.py` |
| `POST /v1/audio/speech`, stream, batch, voices | `test_invalid_audio_speech.py` |
| Audio diffusion endpoints | `test_invalid_audio_diffusion.py` |
| `POST /v1/images/generations` | `test_invalid_image_generation.py` |
| `POST /v1/images/edits` | `test_invalid_image_editing.py` |
| `POST/GET/DELETE /v1/videos*` | `test_invalid_video_generation.py` |
| Sleep / wakeup / server control | `test_invalid_server_control.py` |
3. **Match in-tree style** in the chosen script:
| Element | Convention |
|---------|------------|
| **Module markers** | `pytestmark = [pytest.mark.slow, pytest.mark.<omni or tts or diffusion>]` |
| **Server fixture** | `_PARAMS` / `_QWEN3_TTS_SPEECH`-style list of `pytest.param(OmniServerParams(...), id="...", marks=hardware_marks(...))`; `@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)` |
| **Hardware** | `hardware_marks(res={"cuda": "H100"})` for heavy diffusion/omni/video; `hardware_marks(res={"cuda": "L4"})` for smaller TTS models (must match weekly `-m "slow and L4"` step) |
| **HTTP client** | **Low-level** `openai_client.send_*_http_request({..., "err_code": 400, "err_message": (...)} )` — **not** `send_*_request` (success path) |
| **Case shape** | Prefer **one `test_*` per route family** + `@pytest.mark.parametrize("body_spec, err_message", [...])` with stable `id=` per case; or dedicated `test_<route>_malformed_json` when not parametrized |
| **Body helpers** | `_minimal_<endpoint>_json(omni_server)` / `_minimal_*_form_data()`; merge overrides with `body.update(body_spec)` |
| **Known gaps** | `pytest.mark.skip(reason="…#3649")` as `_SKIP_ISSUE_3649` when server validation is not yet strict (mirror neighboring cases) |
| **Sections** | Route banner comments (`# ─── POST /v1/images/generations ───`) like existing files |
| **Shared fixtures** | Reuse `tests/dfx/reliability/invalid_param_test/conftest.py` (`tiny_png_bytes`, env defaults) |
4. **Adding a new model** to invalid-param coverage: append a `pytest.param(OmniServerParams(model="...", stage_config_path=..., server_args=...), ...)` entry to the script’s `_PARAMS` list — do **not** create `test_invalid_<model>.py` unless the route is new.
5. **CI — `.buildkite/test-weekly.yml` only** (not `test-ready.yml` / `test-merge.yml` / `test-nightly.yml`):
| Weekly step | Command | When your cases run |
|-------------|---------|---------------------|
| **Invalid parameters Test · H100** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and H100"` | Diffusion / omni / video invalid-param tests with `H100` hardware mark |
| **Invalid parameters Test · L4** | `pytest -s -v tests/dfx/reliability/invalid_param_test/ -m "slow and L4"` | TTS / lighter models marked `L4` |
- **Trigger:** `build.env("WEEKLY") == "1"` or PR label `weekly-test`.
- **Default:** extending an existing `invalid_param_test` script needs **no YAML edit** — the weekly steps already sweep the whole directory.
- **Edit YAML only** when adding a **new hardware queue**, a **new top-level script** that must run in isolation, or a **model-specific weekly shard** (mirror neighboring reliability steps).
- Weekly steps **do not** use `source_file_dependencies`.
**Example — append to `test_invalid_image_generation.py`:**
```python
@pytest.mark.parametrize(
"body_spec, err_message",
[
pytest.param({"seed": -1}, ("seed", "greater_than_equal", "0"), id="seed_negative"),
],
)
@pytest.mark.parametrize("omni_server", _PARAMS, indirect=True)
def test_images_generations_invalid_requests(
omni_server: OmniServer,
openai_client: OpenAIClientHandler,
body_spec: dict[str, object],
err_message: str | tuple[str, ...],
) -> None:
body = _minimal_images_gen_json(omni_server)
body.update(body_spec)
openai_client.send_images_generations_http_request(
{"json": body, "timeout": 300, "err_code": 400, "err_message": err_message}
)
```
See [references/test-routing.md](references/test-routing.md) **Invalid parameter / weekly CI**.
#### Assertion helpers (`tests/helpers/assertions.py`)
**Do not** add module-local helpers such as `_assert_images_generations_payload` or `_send_and_assert_*` in e2e test files. Response/media validation belongs in **`tests/helpers/assertions.py`**, grouped by **category**:
| Category | Existing anchors | When to extend vs add |
|----------|------------------|------------------------|
| **Image** (chat diffusion + DALL-E JSON) | `assert_image_diffusion_response`, `assert_image_valid` | DALL-E `/v1/images/generations` JSON → add **`assert_images_generations_response`** beside image helpers (reuses `assert_image_valid`); do not fork decode logic into tests |
| **Video** | `assert_video_diffusion_response`, `assert_video_valid` | Extend these for new video contracts |
| **Audio** | `assert_audio_diffusion_response`, `assert_audio_speech_response`, `assert_audio_valid` | Extend for new TTS/audio endpoints |
| **Omni multimodal** | `assert_omni_response` | Extend for new modality combos |
| **HTTP errors** | `assert_http_error`, `assert_err_message_in_text` | Used inside low-level `send_*_http_request` and negative tests |
| Do | Don't |
|----|-------|
| Put **general** `assert_*` inside `send_*_request` in `runtime.py`; tests call `send_*_request` only | Call `assert_diffusion_response` after `send_diffusion_request` (general assert already bundled) |
| Put **special** `assert_*` in `assertions.py` and call it **in the test** after `send_*_request` when that case needs extra checks | Put general decode/count/size logic in the test because “this case uses `n=4`” (that belongs in general assert reading `request_config`) |
| Extend the matching category function, or add `assert_<endpoint>_response` next to its category | `_send_and_assert_*` or PIL/base64 loops in `test_*.py` |
| Use low-level `send_*_http_request` in dfx/negative tests (`err_code`) | Use low-level HTTP send + manual general assert in every L2+ expansion file |
**Workflow when generating tests:**
1. Implement or reuse **general** `assert_*` in `assertions.py` (by category).
2. Wire it into **`send_*_request`** in `runtime.py` if not already bundled.
3. In the test: `request_config` → `send_*_request`. Add a separate `assert_*` import/call **only** for special case validation.
4. Update `source_file_dependencies` when `runtime.py` or `assertions.py` changes.
**Example — DALL-E `/v1/images/generations`:**
```python
# tests/helpers/assertions.py — general contract (image category)
def assert_images_generations_response(resp_body: dict, request_config: dict, *, run_level: str | None = None) -> None:
... # data[], n, size → assert_image_valid
# tests/helpers/runtime.py — general assert INSIDE send
def send_images_generations_request(self, request_config: dict[str, Any], ...) -> list[HttpResponse]:
responses = self.send_images_generations_http_request(request_config, ...)
if request_config.get("err_code") is None:
body = responses[0].json_body
assert isinstance(body, dict)
assert_images_generations_response(body, request_config, run_level=self.run_level)
return responses
# tests/e2e/.../test_foo_expansion.py — one case per test_* (no case_id branching)
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_images_generations_default_1024(omni_server, openai_client) -> None:
openai_client.send_images_generations_request({"json": body, "timeout": 300})
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_images_generations_sizes_256_512_1024(omni_server, openai_client) -> None:
for size in ("256x256", "512x512", "1024x1024"):
openai_client.send_images_generations_request({"json": {**body, "size": size}, "timeout": 300})
# chat diffusion — separate test; send_diffusion_request bundles assert_diffusion_response
@pytest.mark.parametrize("omni_server", [pytest.param(OmniServerParams(model=MODEL), marks=SINGLE_L4)], indirect=True)
def test_foo_chat_completions_t2i_fallback(omni_server, openai_client) -> None:
openai_client.send_diffusion_request({...})
```
L1 tests under `tests/entrypoints/` may keep **minimal** asserts next to the handler under test when validating a single branch; still prefer `assertions.py` when the same JSON/media check appears in more than one file or level (e.g. L1 protocol test + L4 e2e).
#### Omni Test Writing Guidance (L1-L4 Layering)
When the goal is a general Omni/multimodal test case, prioritize mapping the test to the correct purpose, directory, and resource assumptions aligned with the layers (see [CI_5levels.md](../../../docs/contributing/ci/CI_5levels.md)):
- **L1**: Unit/logic validation on CPU (`core_model and cpu`). Cover input validation, branches, and exception paths (`tests/<component>/test_*.py`). **Mock with `mocker` / `monkeypatch` only — not `unittest.mock`.**
- **L2**: Basic e2e (online/offline basic scenarios). Prefer dummy/lightweight models to validate the end-to-end request-to-output-structure/streaming chain (typically `tests/e2e/online_serving/` and `tests/e2e/offline_inference/`).
- **L3/L4**: Important integration, performance, and accuracy validation. L4 emphasizes “full functional scenarios + performance/stress + runnable doc examples” (typically `*_expansion.py` plus related expansion cases).
Also keep markers consistent with the run level: use `core_model` for L1/L2 and `advanced_model` for L3/L4, and pair with `--run-level` to select the intended CI strategy.
#### Diffusion Test Writing Guidance (L4 Coverage Combinations)
When the task involves diffusion models/features, organize L4 test cases following [`#1832`](https://github.com/vllm-project/vllm-omni/issues/1832): combine multiple diffusion features into as few test cases as possible to fit limited CI GPU resources.
Implementation strategy:
1. If “full L4 is too heavy”: first provide a **reduced local validation case in L1/L2** (so the key assertion and the contract fix point are covered deterministically).
2. Then provide **CI/nightly-ready L3/L4 high-marked cases** (e.g. `advanced_model`) to broaden coverage under resource constraints.
#### L4 nightly sub-pillars — Function vs Accuracy vs Perf
**L4 is not only `test_*_expansion.py`.** In `test-nightly.yml`, each model-type group typically runs **separate jobs**:
| Sub-pillar | Nightly step label pattern | What you add | Typical paths |
|------------|----------------------------|--------------|---------------|
| **Function** | `· Function Test with …` | E2e expansion / feature matrix | `tests/e2e/online_serving/test_<model>_expansion.py` (or offline) |
| **Accuracy** | `· Accuracy Test` | Quality / similarity vs baseline | `tests/e2e/accuracy/test_<model>*.py` |
| **Perf** | `· Perf Test · <Model>` | Throughput / latency / memory benchmarks | `tests/dfx/perf/tests/test_<model>_vllm_omni.json` + runner script |
| **Doc** (optional) | `· Doc Test` | Runnable doc examples | `tests/examples/*/test_text_to_image.py`, … |
**Default when the user asks for “L4 functional cases”:** deliver **Function** pillar only (`*_expansion.py` + Function Test shard in `test-nightly.yml`). **Do not** silently add Perf or Accuracy unless the user also asks for **performance** / **benchmark** / **accuracy** / **full L4** / **full L4 coverage**.
**When the user explicitly asks for L4 perf (or full L4 including perf):**
1. **Do not** put throughput/latency assertions inside `test_*_expansion.py` — perf uses the **dfx benchmark harness**, not `omni_server` e2e fixtures.
2. Add or extend a **JSON config** under `tests/dfx/perf/tests/` (mirror in-tree names: `test_qwen_image_vllm_omni.json`, `test_bagel_vllm_omni.json`).
3. Each JSON entry: `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]` with explicit **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`, …).
4. Run locally via the matching script (model type → runner):
| Model type | Runner script | Example config |
|------------|---------------|----------------|
| **Diffusion X2I/X2V** | `tests/dfx/perf/scripts/run_diffusion_benchmark.py` | `tests/dfx/perf/tests/test_qwen_image_vllm_omni.json` |
| **TTS** | `tests/dfx/perf/scripts/run_benchmark.py` | `tests/dfx/perf/tests/test_tts.json` (shared) or `test_{slug}.json` (dedicated; use when the model must not join the shared nightly matrix before integration — e.g. `test_voxcpm2.json`, `test_coqui_tts.json`) |
| **Omni** | (see existing Omni Perf steps — `run_benchmark.py` / dedicated omni configs in-tree) | `tests/dfx/perf/tests/test_omni*.json` |
5. Wire **`test-nightly.yml` · Perf Test** step for that model (separate from Function Test): export `DIFFUSION_BENCHMARK_DIR` / `BENCHMARK_DIR`, run pytest on the script with `--test-config-file`, **upload artifacts** (`buildkite-agent artifact upload`), often **multi-GPU H100** for diffusion perf.
6. **One benchmark scenario → one `test_name` block** in JSON (same spirit as one `test_*` per function case). Combine server `serve_args` + workload in one entry; use multiple `benchmark_params` rows for size/step sweeps under the same server config.
**Perf local command (diffusion example):**
```bash
cd tests
export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \
--test-config-file dfx/perf/tests/test_<model>_vllm_omni.json
```
**Clarify in the test plan** which L4 pillars you deliver: `Function only` | `Function + Perf` | `Function + Accuracy + Perf`.
See [references/test-routing.md](references/test-routing.md) **L4 nightly pillars** for Buildkite step patterns.
### Step 5: Wire Buildkite (when CI must run the new test)
If the test is not already collected by an existing pipeline command (for example, L1 tests marked `core_model and cpu` are already covered by the **Simple Unit Test** step in ready/merge), update the appropriate pipeline under `.buildkite/`:
| Test level | Edit this file | Typical trigger / intent |
|------------|----------------|---------------------------|
| **L1** and **L2** | [`.buildkite/test-ready.yml`](../../../.buildkite/test-ready.yml) | PR **ready** label; L1 CPU + L2 GPU/basic e2e (steps labeled **Omni ·**, **TTS ·**, **Diffusion ·**) |
| **L3** | [`.buildkite/test-merge.yml`](../../../.buildkite/test-merge.yml) | Post-merge; `advanced_model` integration per model type |
| **L4** | [`.buildkite/test-nightly.yml`](../../../.buildkite/test-nightly.yml) | Nightly; grouped by model type (see below) |
| **Invalid param / reliability (weekly)** | [`.buildkite/test-weekly.yml`](../../../.buildkite/test-weekly.yml) | Weekly; `tests/dfx/reliability/invalid_param_test/` — **not** L1–L4 e2e pipelines |
**Level-specific Buildkite delivery (follow the user’s requested level):**
| User asks for | Document / edit **only** | Do **not** add by default |
|---------------|---------------------------|---------------------------|
| **L4** (`*_expansion.py`, `full_model`) | `test-nightly.yml` — append file to the matching nightly shard (X2I / X2V / Omni / TTS) **or** note it is already collected by an existing `-m` / `-k` sweep | `test-merge.yml` (L3 / `advanced_model`) or `test-ready.yml` (L2) |
| **L3** | `test-merge.yml` with `advanced_model` + `source_file_dependencies` | `test-nightly.yml` unless user also wants nightly |
| **L2** | `test-ready.yml` with `core_model` + `source_file_dependencies` | merge / nightly pipelines |
| **Invalid param** | `test-weekly.yml` — **Invalid parameters Test** group (`-m "slow and H100"` / `-m "slow and L4"`). Usually **no YAML edit** when appending cases to existing `invalid_param_test` scripts | `test-ready.yml`, `test-merge.yml`, `test-nightly.yml` |
`test-nightly.yml` shards use **explicit pytest file paths** in `commands` (and PR labels like `diffusion-x2iat-test`); they generally **do not** use `source_file_dependencies`. Only suggest merge/ready YAML when the requested level is L3/L2 (or the user explicitly asks for multi-level CI).
**`test-nightly.yml` top-level groups** (each uses `full_model` + type marker in pytest `-m`):
| Group | Type marker | Notes |
|-------|-------------|--------|
| **Omni Model Test** | `omni` | Function / doc / accuracy / **perf** for Qwen-Omni family |
| **TTS Model Test** | `tts` | `-m "full_model and L4 and tts"` (and H100 variants when enabled); **Perf Test** uses `run_benchmark.py` + `test_tts.json` |
| **Diffusion X2I(&A&T) Model Test** | `diffusion` | Image/audio/text diffusion; explicit file shards for **Function**; **Perf Test · &lt;Model&gt;** uses `run_diffusion_benchmark.py` + JSON config |
| **Diffusion X2V Model Test** | `diffusion` | Video-only (Wan, HunyuanVideo, …); PR label `diffusion-x2v-test`; separate **Perf** steps when in-tree |
Guidelines when editing YAML:
- **Match markers and `--run-level`** to the test: L1/L2 in `test-ready.yml` use `core_model` + `omni` / `tts` / `diffusion` as appropriate; L3 merge uses `advanced_model`; L4 nightly uses `full_model` with the same type marker.
- **Diffusion L4**: add x2i/x2a/x2t expansion files to an **X2I(&A&T)** step; add x2v expansion files to **X2V** — never rely on a broad `tests/e2e/` sweep alone (shards exist to save GPU and avoid wrong queue).
- **`source_file_dependencies` (required for new E2E jobs in `test-ready.yml` and `test-merge.yml`)**: every step under the **`:card_index_dividers: E2E Test`** group **must** declare `source_file_dependencies` so Buildkite only schedules the job when relevant paths change. Omitting it breaks path-based CI skipping for GPU e2e jobs.
- **Reuse or extend an existing step** when the new test shares the same marker expression, queue, and timeout; otherwise add a new `steps:` entry with the correct `agents.queue`, `timeout_in_minutes`, and docker/kubernetes plugin blocks consistent with neighboring jobs.
- **Job timeout — use `timeout_in_minutes` on the step, not `timeout` before pytest**: when adding or documenting a new E2E job in `test-ready.yml` / `test-merge.yml`, set `timeout_in_minutes` on the step and run `pytest` directly in `commands`. **Do not** wrap pytest in `timeout 40m bash -c "..."` / `timeout 20m bash -c "..."`; Buildkite already enforces the step deadline via `timeout_in_minutes`.
- **Never omit `plugins` in skill examples or PR YAML snippets**: H100 E2E steps on `mithril-h100-pool` use the full `kubernetes` block (`resources`, `volumeMounts`, `env` with `HF_TOKEN` secret, `nodeSelector: gpu-h100-sxm`, `devshm` + `hf-cache` volumes). L4/docker steps on `gpu_1_queue` use the full `docker#v5.2.0` block (`shm-size`, `HF_HOME`, volumes). Do not write `# ... plugins unchanged` — paste the complete block from a sibling step.
- **Platform forks** (e.g. AMD-ready / AMD-merge) live alongside these files; apply the same level → file mapping for those pipelines when the test is platform-specific.
#### `source_file_dependencies` for E2E Test jobs (`test-ready.yml`, `test-merge.yml`)
When adding or splitting a step inside the **E2E Test** group, **always** include `source_file_dependencies` on that step. List paths the job actually depends on — at minimum:
1. **Test module(s)** — every `tests/e2e/...` file the `pytest` command runs (online and offline if both are in one step).
2. **Model implementation** — `vllm_omni/model_executor/models/<family>/` and/or `vllm_omni/diffusion/models/<family>/` as applicable.
3. **Stage / input plumbing** (omni & multi-stage TTS) — `vllm_omni/model_executor/stage_input_processors/<family>.py` when present.
4. **Deploy / stage YAML** — `vllm_omni/deploy/<config>.yaml` or `vllm_omni/deploy/ci/<config>.yaml` referenced by the test’s `OmniServerParams` / `get_deploy_config_path(...)`.
5. **Test helpers** (when changed in the same PR) — `tests/helpers/runtime.py` for new `send_*` client methods; `tests/helpers/assertions.py` for new shared `assert_*` helpers.
Use **repo-relative paths** (no leading `./`). Prefer **directory** entries (`.../models/bagel/`) when the whole package matters; use **file** entries for single test modules and YAML configs. Mirror a neighboring step for the same model family (e.g. **Diffusion · Bagel Test** in `test-ready.yml`).
**Example** (L2 ready — diffusion online smoke, H100 kubernetes):
```yaml
- label: "Diffusion · Bagel Test"
source_file_dependencies:
- tests/e2e/online_serving/test_bagel.py
- vllm_omni/model_executor/models/bagel/
- vllm_omni/diffusion/models/bagel/
- vllm_omni/model_executor/stage_input_processors/bagel.py
- vllm_omni/deploy/bagel.yaml
timeout_in_minutes: 40
commands:
- |
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v tests/e2e/online_serving/test_bagel.py -m 'core_model' --run-level 'core_model'
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
```
**Example** (L3 merge — TTS online + offline, L4 docker):
```yaml
- label: "TTS · Qwen3-TTS Base Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen3_tts_base.py
- tests/e2e/offline_inference/test_qwen3_tts_base.py
- vllm_omni/model_executor/models/qwen3_tts/
- vllm_omni/model_executor/stage_input_processors/qwen3_tts.py
- vllm_omni/deploy/qwen3_tts.yaml
timeout_in_minutes: 20
commands:
- |
export VLLM_LOGGING_LEVEL=DEBUG
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
pytest -s -v tests/e2e/online_serving/test_qwen3_tts_base.py tests/e2e/offline_inference/test_qwen3_tts_base.py -m 'advanced_model and cuda' --run-level 'advanced_model'
agents:
queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
```
**Example** (L3 merge — diffusion online + offline, H100 kubernetes):
```yaml
- label: "Diffusion · Qwen Image Test"
source_file_dependencies:
- tests/e2e/online_serving/test_qwen_image.py
- tests/e2e/offline_inference/test_qwen_image.py
- vllm_omni/diffusion/models/qwen_image/
timeout_in_minutes: 40
commands:
- |
export VLLM_IMAGE_FETCH_TIMEOUT=60
pytest -s -v \
tests/e2e/online_serving/test_qwen_image.py \
tests/e2e/offline_inference/test_qwen_image.py \
-m 'advanced_model and cuda' --run-level 'advanced_model'
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
```
When extending an **existing** E2E step to run an additional test file, **append** that file (and any new model/deploy paths) to `source_file_dependencies` on the same step. When authoring a PR, mention the dependency list in the **Buildkite change** section of your test plan.
#### L3 / L4: validating Buildkite from a feature branch (root `pipeline.yml`)
Production behavior is driven by [`.buildkite/pipeline.yml`](../../../.buildkite/pipeline.yml): it builds the CI image, then uploads **L2** (`test-ready.yml` on non-`main`), **L3** (`test-merge.yml` when `build.branch == "main"`), and **L4** (`test-nightly.yml` when `build.env("NIGHTLY") == "1"`).
When you add or debug **L3** or **L4** tests and need those child pipelines to run **off `main`** or **without** `NIGHTLY=1`, apply **temporary** edits (revert before merge):
1. **Point the upload at the right file (optional shortcut)**
In the **Upload Ready Pipeline** step, the command is `buildkite-agent pipeline upload .buildkite/test-ready.yml`. For a one-off run you may change that path to `.buildkite/test-merge.yml` (L3) or `.buildkite/test-nightly.yml` (L4), and adjust the step’s `if:` so it does not conflict with the other upload steps (avoid double-uploading unless intentional).
2. **L3 — merge pipeline**
The gate `if: build.branch == "main"` lives on the **Upload Merge Pipeline** step in `pipeline.yml` (not inside `test-merge.yml`). **Comment out that `if` line** so `test-merge.yml` is uploaded on your feature branch. Alternatively rely on step (1) instead of the dedicated merge upload step.
3. **L4 — nightly pipeline**
**Comment out** `if: build.env("NIGHTLY") == "1"` on the **Upload Nightly Pipeline** step in `pipeline.yml` so the nightly definition is uploaded without setting the env var.
Child steps in [`test-nightly.yml`](../../../.buildkite/test-nightly.yml) often repeat `if: build.env("NIGHTLY") == "1"`; **comment out those lines on the steps you need to run**, otherwise they will still be skipped after upload.
### Step 6: Run Tests
Pick one command path:
- **Quick local regression (preferred first)**: single file or `file.py::test_name`
- **CI-like level run**: marker expression + `--run-level`
Full templates live in [references/test-routing.md](references/test-routing.md). **After authoring tests, always emit concrete commands** (see **Output Format**).
**Typical copy-paste examples** (run from repo `tests/` directory; requires matching vLLM/vllm-omni install and hardware):
| Area | Example |
|------|---------|
| **Omni offline L2** | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py -m "core_model and omni and not cpu" --run-level=core_model` |
| **Omni online L2** | `pytest -s -v e2e/online_serving/test_qwen3_omni.py -m "core_model and omni" --run-level=core_model` |
| **TTS online L2** | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py -m "core_model and tts" --run-level=core_model` |
| **Diffusion X2I L2 online** | `pytest -s -v e2e/online_serving/test_qwen_image.py -m "core_model and diffusion" --run-level=core_model` |
| **Diffusion X2V L2 online** | `pytest -s -v e2e/online_serving/test_wan22_t2v.py -m "core_model and diffusion" --run-level=core_model` |
| **Diffusion X2I L4 nightly** | `pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
| **Diffusion X2I L4 perf (nightly)** | `pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json` |
| **Diffusion X2V L4 nightly** | `pytest -s -v e2e/online_serving/test_wan22_expansion.py -m "full_model and cuda" --run-level=full_model` |
| **Invalid param (weekly H100)** | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"` |
| **Invalid param (weekly L4)** | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"` |
| **L1 CPU** | `pytest -s -v -m "core_model and cpu"` |
**Prerequisites to mention when relevant**: GPU model (e.g. L4 vs H100), `HF_HOME` / token for hub weights, module-level `skipif` (NPU/XPU-only gaps), and whether CI already collects the path (e.g. `test_*_expansion.py` glob in `test-nightly.yml`).
### Step 7: Validate Result Quality
Before finishing:
- Is the new assertion directly tied to the bug/feature contract?
- Are API calls made via **`runtime.py` `send_*_request`** (not inline HTTP/SDK in the test file)?
- Is the **general** `assert_*` bundled inside `send_*_request` (not duplicated in the test)?
- Is each scenario a **separate `test_*` function** (no `case_id` / `if param.id` branching across cases)?
- **Fixture scope**: default **`omni_server` / `omni_runner`** (module); **`_function`** variants only when each test must restart the instance?
- Is the test deterministic (no fragile timing/network coupling)?
- Is runtime appropriate for the selected level?
- Are markers and `--run-level` consistent?
- **Invalid-param cases** in `tests/dfx/reliability/invalid_param_test/` (not e2e), using `send_*_http_request` + `err_code`, with `pytest.mark.slow` and correct `H100` / `L4` hardware mark?
## Output Format
When completing a request, return:
1. **Test plan** (level, markers, file target, **L4 pillar(s)** if applicable: Function / Accuracy / Perf / Doc; **or** **Invalid param** pillar with target `invalid_param_test/test_invalid_<area>.py`; module basename for e2e: `test_{slug}.py` / `test_{slug}_expansion.py`)
2. **Generated/updated test file(s)** — model-specific constants and request payloads stay **inside** those test modules (**never** `tests/helpers/{slug}.py`). Extend **`tests/helpers/runtime.py`** / **`tests/helpers/assertions.py`** only when new **repo-wide** `send_*` or shared assert helpers are required (do not leave ad-hoc HTTP or `_assert_*` in test modules). **If L4 Perf was requested**, also list **`tests/dfx/perf/tests/*.json`**. **If invalid-param cases were requested**, list the **`invalid_param_test/` script** and parametrized `test_*` / new `id=` rows — **not** e2e paths.
3. **Buildkite change** — **match the requested level and pillar**:
- **L4 Function**: `test-nightly.yml` **Function Test** shard (file list / note existing sweep). **No** `test-merge.yml` unless user also asked for L3.
- **L4 Perf**: `test-nightly.yml` **Perf Test · &lt;Model&gt;** step (new step or extend commands); include env exports + artifact upload pattern from a sibling perf job.
- **L4 Accuracy**: `test-nightly.yml` **Accuracy Test** step under the same model-type group.
- **L3**: `test-merge.yml` + full `source_file_dependencies` + `agents` + `plugins`.
- **L2**: `test-ready.yml` + same E2E block requirements.
- **Invalid param**: `test-weekly.yml` — note **Invalid parameters Test · H100/L4** group; usually **no YAML change** when only extending existing scripts.
If the user asked only for **L4 functional cases**, state explicitly that **Perf / Accuracy / Invalid param** were not included (offer to add if needed). If the plan mixes **success e2e** and **invalid param**, split deliverables across e2e vs `invalid_param_test/` and call out both CI files.
4. **Run commands (required)** — always include, in fenced `bash` blocks:
- **Local — whole file**: `cd tests` then `pytest -s -v <path> …`
- **Local — single test** (optional but preferred when the change is one function): `pytest -s -v path::test_func …`
- **CI-like** (when not L1 CPU): the same **marker + `--run-level`** pairing the level uses (see [references/test-routing.md](references/test-routing.md) and **Step 6** table above)
- **Prerequisites** (one line): e.g. “Requires CUDA L4 + weights cached locally”; “Requires H100 + `HF_TOKEN`”; “Syntax-only validation when vllm is not installed locally”
5. **Result summary** (pass/fail if you executed tests; if not executed, state that explicitly and what the user should run)
## Additional Resources
- Marker and command routing: [references/test-routing.md](references/test-routing.md)
- CI pipelines (vllm-omni): [test-ready.yml](../../../.buildkite/test-ready.yml) (L1/L2), [test-merge.yml](../../../.buildkite/test-merge.yml) (L3), [test-nightly.yml](../../../.buildkite/test-nightly.yml) (L4), [test-weekly.yml](../../../.buildkite/test-weekly.yml) (invalid param / reliability)
- L4 documentation example tests (naming, extraction vs copied scripts, output dirs, skips): [docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md](../../../docs/contributing/ci/test_examples/l4_doc_example_tests.inc.md) — see also [PR #1910](https://github.com/vllm-project/vllm-omni/pull/1910)
@@ -0,0 +1,372 @@
# Test Routing Reference
Use this reference to map testing goals to levels, markers, and runnable commands.
**Repo paths** (`.buildkite/`, `docs/contributing/…`): link with repo-relative paths from this file (e.g. `../../../../.buildkite/test-ready.yml`, `../../../../docs/contributing/ci/CI_5levels.md`).
## Model-centric e2e filename convention (L2–L4)
When generating a **new** test module tied to a **specific model** under `tests/e2e/offline_inference/` or `tests/e2e/online_serving/`:
| Level | Pattern | Example (`Qwen/Qwen2.5-Omni-7B`) |
|-------|---------|----------------------------------|
| **L2**, **L3** | `test_{lowercase_model_slug}.py` | `test_qwen2_5_omni.py` |
| **L4** | `test_{lowercase_model_slug}_expansion.py` | `test_qwen2_5_omni_expansion.py` |
**Slug rules for `{lowercase_model_slug}`** (canonical: [SKILL.md](../SKILL.md) § *Naming: generated test module files*):
1. Start from the HuggingFace-style id (e.g. `Qwen/Qwen2.5-Omni-7B`), but **do not** put the org into the filename: use the **repo segment only** (`Qwen2.5-Omni-7B`), not `Qwen_...` / `qwen_qwen2_5_...`.
2. **Lowercase**; replace `.`, `-`, and whitespace with a single `_` (e.g. `Qwen2.5-Omni-7B` → `qwen2_5_omni`). **Omit** trailing size tokens such as `7b` / `30b` in the basename when a single file covers that model line in the directory (matches `test_qwen2_5_omni.py` in-tree).
3. If two checkpoints in the same folder need separate modules, add a **minimal** disambiguator (e.g. `_7b` vs `_3b`) **only then**.
4. **L1** unit tests are **not** bound to this pattern; use `tests/<area>/test_<feature>.py` as today.
## Model type markers (`omni` / `tts` / `diffusion`)
Every model-centric e2e test must declare **exactly one** type marker:
| Marker | Model family | Typical APIs |
|--------|--------------|--------------|
| `pytest.mark.omni` | Multimodal LLM (Qwen-Omni, …) | `send_omni_request`, `omni_runner`, stage YAML |
| `pytest.mark.tts` | TTS / speech synthesis | `send_audio_speech_request`, `/v1/audio/speech` |
| `pytest.mark.diffusion` | Diffusion generative models | `send_diffusion_request`, `send_video_diffusion_request`, `send_images_generations_http_request`, `OmniDiffusionSamplingParams` |
**Diffusion** uses one marker for all output modalities. Nightly CI splits diffusion by **group**, not by extra markers:
| Nightly group (`test-nightly.yml`) | Diffusion scope | Examples |
|-----------------------------------|-----------------|----------|
| **Diffusion X2I(&A&T) Model Test** | x2**i** / x2**a** / x2**t** — image, audio, text (non-video) | Qwen-Image*, BAGEL, FLUX, SD3, Z-Image, LongCat, DreamZero |
| **Diffusion X2V Model Test** | x2**v** — video only | Wan2.2, HunyuanVideo 1.5, Wan VACE |
PR labels for selective nightly runs: `diffusion-x2iat-test`, `diffusion-x2v-test` (plus `nightly-test` / `NIGHTLY=1`).
## Level and Marker Mapping
| Goal | Suggested Level | Marker baseline | Typical location |
|------|------------------|-----------------|------------------|
| Unit logic, regression on pure code path | L1 | `core_model and cpu` | `tests/<component>/test_*.py` |
### L1 unit tests — mocking (`mocker`, not `unittest.mock`)
L1 modules run in **Simple Test** (`test-ready.yml` / `test-merge.yml`: `-m 'core_model and cpu'`). When isolation requires doubles:
- **Use** the **`mocker`** fixture (`pytest-mock`): `mocker.patch`, `mocker.spy`, `mocker.Mock`, `mocker.MagicMock`, `mocker.AsyncMock`.
- **Use** **`monkeypatch`** for env vars and simple `setattr` / `delattr` without mock objects.
- **Do not use** `unittest.mock` — no `from unittest.mock import ...`, no `@patch`, no `with patch(...)`.
```python
# Good (L1)
def test_handler(mocker):
mocker.patch("vllm_omni.pkg.fn", return_value=0)
# Bad (L1)
from unittest.mock import patch
@patch("vllm_omni.pkg.fn")
def test_handler(mock_fn): ...
```
E2E (L2+) should not rely on mocks unless documenting a rare exception; prefer real (or lightweight) execution paths.
| Basic integration/e2e | L2 | `core_model` + **one of** `omni` / `tts` / `diffusion` | `tests/e2e/...` |
| Advanced integration | L3 | `advanced_model` + type marker | `tests/e2e/...` |
| Full function / nightly | L4 | `full_model` (nightly) + type marker | **Function:** `tests/e2e/*_expansion.py`; **Perf:** `tests/dfx/perf/tests/*.json`; **Accuracy:** `tests/e2e/accuracy/` |
| Invalid HTTP / param validation | Weekly (dfx) | `pytest.mark.slow` + type marker + `H100` or `L4` | `tests/dfx/reliability/invalid_param_test/test_invalid_*.py` |
## Marker Selection Rules
1. **Level** (pick one):
- `core_model` — L1/L2 (`test-ready.yml`)
- `advanced_model` — L3 (`test-merge.yml`)
- `full_model` — L4 nightly (`test-nightly.yml`); some expansion tests still carry both `advanced_model` and `full_model` during migration
2. **Model type** (pick one): `omni`, `tts`, or `diffusion`
3. **Cross-cutting** when relevant: `parallel`, `cache`, `example`, `benchmark`
4. **Hardware**: `cpu`, `cuda`, `rocm`, `npu`, `L4`, `H100`, `distributed_cuda`, …
5. Multi-card: `@hardware_test(...)` in `tests/helpers/mark.py`
## Command Templates
### Quick local checks
```bash
cd tests
pytest -s -v test_xxxx.py
```
### L1
```bash
cd tests
pytest -s -v -m "core_model and cpu"
```
### L2
```bash
cd tests
pytest -s -v -m "core_model and not cpu" --run-level=core_model
```
### L3 (merge)
```bash
cd tests
pytest -s -v -m "advanced_model" --run-level=advanced_model
```
### L4 (nightly)
**Function** (e2e expansion — default when user asks for “L4 functional cases”):
```bash
cd tests
pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model
```
**Perf** (dfx benchmark harness — separate pillar; not `omni_server` fixtures):
```bash
cd tests
export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py \
--test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json
```
Broad marker sweep (when no explicit file shard):
```bash
cd tests
pytest -s -v -m "full_model" --run-level=full_model
```
## L4 nightly pillars (Function / Accuracy / Perf)
Nightly **L4** for a model is often **multiple jobs** in `test-nightly.yml`, not a single pytest module:
| Pillar | User says | Deliver | Nightly step |
|--------|-----------|---------|--------------|
| **Function** | “L4 functional cases” / “functional” / `*_expansion.py` | `tests/e2e/.../test_<model>_expansion.py` | `· Function Test with H100/L4` |
| **Perf** | “performance” / “perf” / “benchmark” / “full L4” | `tests/dfx/perf/tests/test_<model>_vllm_omni.json` + runner | `· Perf Test · <Model>` |
| **Accuracy** | “accuracy” / “similarity” | `tests/e2e/accuracy/test_<model>*.py` | `· Accuracy Test` |
**Rule:** “L4 functional cases” → **Function only** by default. State that Perf/Accuracy are separate pillars; add them only when requested.
**Diffusion perf JSON** (`tests/dfx/perf/tests/test_<slug>_vllm_omni.json`): array of objects with `test_name`, `server_params` (`model`, `serve_args`), `benchmark_params[]`, and per-workload **`baseline`** (`throughput_qps`, `latency_mean`, `peak_memory_mb_mean`). Copy structure from `test_qwen_image_vllm_omni.json` in-tree.
**Diffusion perf nightly step** (under **Diffusion X2I(&A&T)**; copy full `kubernetes` plugins from `· Perf Test · Qwen-Image`):
```yaml
- label: ":full_moon: Diffusion X2I(&A&T) · Perf Test · <Model-Display-Name>"
key: nightly-diffusion-x2iat-performance-<slug>
timeout_in_minutes: 180
commands:
- export DIFFUSION_BENCHMARK_DIR=tests/dfx/perf/results
- export DIFFUSION_ATTENTION_BACKEND=FLASH_ATTN
- export CACHE_DIT_VERSION=1.3.0
- |
set +e
pytest -s -v tests/dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_<slug>_vllm_omni.json
EXIT=$$?
buildkite-agent artifact upload "tests/dfx/perf/results/diffusion_result_*.json"
buildkite-agent artifact upload "tests/dfx/perf/results/logs/*.log"
exit $$EXIT
agents:
queue: "mithril-h100-pool"
plugins:
# … full kubernetes block (often multi-GPU) — copy from Perf Test · Qwen-Image
```
| Model type | Perf runner | Config example |
|------------|-------------|----------------|
| Diffusion X2I/X2V | `dfx/perf/scripts/run_diffusion_benchmark.py` | `test_qwen_image_vllm_omni.json` |
| TTS | `dfx/perf/scripts/run_benchmark.py` | `test_tts.json` (`BENCHMARK_DIR`) |
| Omni | `run_benchmark.py` / omni configs in-tree | `test_omni*.json` |
Do **not** put throughput/latency baselines inside `test_*_expansion.py` — that belongs in the dfx perf JSON + nightly Perf job.
## Invalid parameter validation (weekly / dfx)
When the user’s test plan includes **invalid parameter validation**, **invalid request bodies**, or **HTTP 4xx** validation against a live server:
1. **Do not** author these in `tests/e2e/online_serving/test_*.py` or `*_expansion.py`. **Move** any drafted `test_*` into `tests/dfx/reliability/invalid_param_test/`.
2. **Pick script by route** (extend in-tree file):
| Route family | Script |
|--------------|--------|
| Omni chat / WS video·realtime | `test_invalid_omni_chat.py` |
| `/v1/audio/speech` (+ stream / batch / voices) | `test_invalid_audio_speech.py` |
| Audio diffusion | `test_invalid_audio_diffusion.py` |
| `/v1/images/generations` | `test_invalid_image_generation.py` |
| `/v1/images/edits` | `test_invalid_image_editing.py` |
| `/v1/videos*` | `test_invalid_video_generation.py` |
| Sleep / wakeup / server control | `test_invalid_server_control.py` |
3. **Style:** `pytestmark = [pytest.mark.slow, pytest.mark.<type>]`; `_PARAMS` + `hardware_marks`; `send_*_http_request` with `err_code` + `err_message`; parametrized `body_spec` rows with `id=`; `_minimal_*_json()` helpers; `_SKIP_ISSUE_3649` when tracked in [#3649](https://github.com/vllm-project/vllm-omni/issues/3649).
4. **CI:** [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) group **Reliability Test - Invalid parameters Test** — **not** ready/merge/nightly.
```bash
# Weekly H100 (diffusion / omni / video invalid-param)
cd tests
pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"
# Weekly L4 (e.g. Qwen3-TTS speech invalid-param)
pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"
# Single script / case
pytest -s -v dfx/reliability/invalid_param_test/test_invalid_image_generation.py::test_images_generations_invalid_requests -m "slow and H100"
```
**Trigger:** `WEEKLY=1` or PR label `weekly-test`. Appending cases to existing scripts usually needs **no YAML edit** (directory sweep). No `source_file_dependencies` on weekly steps.
### Platform-targeted examples
```bash
cd tests
pytest -s -v -m "core_model and distributed_cuda and L4" --run-level=core_model
```
### Concrete e2e paths (common in-tree)
Paths are relative to `tests/` after `cd tests`.
| Scenario | Example command |
|----------|------------------|
| **Omni** offline L2 | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py -m "core_model and omni and not cpu" --run-level=core_model` |
| **Omni** offline L2 — one test | `pytest -s -v e2e/offline_inference/test_qwen2_5_omni.py::test_text_to_text -m "core_model and omni and not cpu" --run-level=core_model` |
| **Omni** online L2 | `pytest -s -v e2e/online_serving/test_qwen3_omni.py -m "core_model and omni" --run-level=core_model` |
| **Omni** offline L2 (Qwen3.5-9B VL) | `pytest -s -v e2e/offline_inference/test_qwen3_5_9b.py -m "core_model and omni and not cpu" --run-level=core_model` |
| **TTS** online L2 | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py -m "core_model and tts" --run-level=core_model` |
| **TTS** online L2 — one test | `pytest -s -v e2e/online_serving/test_qwen3_tts_base.py::test_text_to_audio_001 -m "core_model and tts" --run-level=core_model` |
| **Diffusion X2I** offline L2 | `pytest -s -v e2e/offline_inference/test_t2i_model.py -m "core_model and diffusion and not cpu" --run-level=core_model` |
| **Diffusion X2I** L2 online smoke | `pytest -s -v e2e/online_serving/test_qwen_image.py e2e/online_serving/test_bagel.py -m "core_model and diffusion" --run-level=core_model` |
| **Diffusion X2V** L2 online smoke | `pytest -s -v e2e/online_serving/test_wan22_t2v.py -m "core_model and diffusion" --run-level=core_model` |
| **Diffusion X2I** L4 online expansion (H100) | `pytest -s -v e2e/online_serving/test_qwen_image_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
| **Diffusion X2I** L4 perf (nightly) | `pytest -s -v dfx/perf/scripts/run_diffusion_benchmark.py --test-config-file dfx/perf/tests/test_qwen_image_vllm_omni.json` |
| **Diffusion X2I** L4 online expansion (Edit) | `pytest -s -v e2e/online_serving/test_qwen_image_edit_expansion.py -m "full_model and diffusion and H100" --run-level=full_model` |
| **Diffusion X2V** L4 nightly | `pytest -s -v e2e/online_serving/test_wan22_expansion.py -m "full_model and cuda" --run-level=full_model` |
| **Invalid param** weekly H100 | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and H100"` |
| **Invalid param** weekly L4 | `pytest -s -v dfx/reliability/invalid_param_test/ -m "slow and L4"` |
### Agent / author completion checklist
When adding or modifying tests, do not stop at “where the file lives” — also deliver:
1. **Local**: `cd tests` + `pytest -s -v <path>` (and `path::test_func` when a single case is enough).
2. **CI-like**: marker string + `--run-level` matching the pipeline (`core_model` / `advanced_model` / `full_model`).
3. **L1 mocks**: `mocker` / `monkeypatch` only; no `unittest.mock`.
4. **API client + assert placement**: **General** validation → implement in `assertions.py`, call **inside** `send_*_request` in `runtime.py`; tests call **`send_*_request` only**. **Special** case validation → `assert_*` in `assertions.py`, called **in the test** after `send_*_request`. Low-level `send_*_http_request` is for negative/dfx tests (`err_code`), not ordinary L2+ success e2e.
5. **Shared assertions**: logic in **`tests/helpers/assertions.py`**; general checks inside `send_*_request`; special checks only in the test. No `_assert_*` in `test_*.py`.
6. **One case → one `test_*`**: function name reflects what is validated; no `if case_id == ...` mega-test merging multiple scenarios.
7. **Fixture scope**: default **`omni_server` + `openai_client`** / **`omni_runner` + `omni_runner_handler`** (module). Use **`omni_server_function` + `openai_client_function`** / **`omni_runner_function` + `omni_runner_handler_function`** only when each `test_*` must spawn a fresh instance. Parametrize name must match fixture (`omni_server` vs `omni_server_function`).
8. **Type marker**: `omni`, `tts`, or `diffusion` on every model e2e module.
9. **Diffusion L4 Function**: wire `*_expansion.py` into **X2I(&A&T)** or **X2V** **Function Test** in **`test-nightly.yml` only** — do not add `test-merge.yml` unless the user also requested L3.
10. **Diffusion L4 Perf** (only when requested): add `tests/dfx/perf/tests/test_<slug>_vllm_omni.json` + **Perf Test · &lt;Model&gt;** step (artifact upload); not part of “L4 functional cases” by default.
11. **E2E Buildkite (L2/L3 only)**: `test-ready.yml` / `test-merge.yml` steps need **`source_file_dependencies`** + full **`agents` + `plugins`**. **L4 nightly** uses explicit file lists or perf scripts in `test-nightly.yml` (no merge job).
12. **Invalid param**: cases in **`tests/dfx/reliability/invalid_param_test/`** (route-matching script), `send_*_http_request` + `err_code`, `pytest.mark.slow` + `H100`/`L4`; CI = **`test-weekly.yml`** only — do not put in e2e or nightly.
13. **Prerequisites**: GPU tier, HF cache/token, and any module `skipif` / platform-only YAML.
## Buildkite pipeline mapping
| Level | Repo file | Model-type grouping |
|-------|-----------|---------------------|
| L1, L2 | [`.buildkite/test-ready.yml`](../../../../.buildkite/test-ready.yml) | Steps prefixed **Omni ·**, **TTS ·**, **Diffusion ·** under **E2E Test** — **`source_file_dependencies` required** |
| L3 | [`.buildkite/test-merge.yml`](../../../../.buildkite/test-merge.yml) | Per-model E2E steps; `-m "advanced_model and …"` — **`source_file_dependencies` required** |
| L4 | [`.buildkite/test-nightly.yml`](../../../../.buildkite/test-nightly.yml) | **Omni / TTS / Diffusion X2I(&A&T) / X2V** — each group may have **Function**, **Accuracy**, **Perf**, **Doc** steps |
| Invalid param (weekly) | [`.buildkite/test-weekly.yml`](../../../../.buildkite/test-weekly.yml) | **Invalid parameters Test · H100** / **· L4** — sweeps `tests/dfx/reliability/invalid_param_test/` |
### `source_file_dependencies` (E2E Test only — ready & merge)
Required on each step inside the **E2E Test** group in `test-ready.yml` and `test-merge.yml`. Typical entries:
| Category | Path pattern |
|----------|----------------|
| Test module | `tests/e2e/online_serving/test_<slug>.py`, `tests/e2e/offline_inference/test_<slug>.py` |
| API client helpers | `tests/helpers/runtime.py` (when the e2e job uses newly added `send_*` methods) |
| Shared assert helpers | `tests/helpers/assertions.py` (when the e2e job uses newly added `assert_*` helpers) |
| AR / omni model | `vllm_omni/model_executor/models/<family>/` |
| Diffusion model | `vllm_omni/diffusion/models/<family>/` |
| Stage processor | `vllm_omni/model_executor/stage_input_processors/<family>.py` |
| Deploy config | `vllm_omni/deploy/<name>.yaml` or `vllm_omni/deploy/ci/<name>.yaml` |
Copy the dependency block from the closest in-tree E2E step for the same model family. Update the list whenever the pytest command or server YAML changes.
### E2E `agents` + `plugins` blocks (required — do not truncate)
When generating or documenting E2E steps, **always include the complete `plugins` section**. Set **`timeout_in_minutes`** on the step for the job deadline; run **`pytest` directly** in `commands` (no `timeout 40m bash -c` wrapper around pytest).
Common patterns in `test-ready.yml` / `test-merge.yml`:
| Queue | Plugin | Required fields |
|-------|--------|-----------------|
| `mithril-h100-pool` | `kubernetes` | `resources.limits.nvidia.com/gpu`, `volumeMounts` (`devshm`, `hf-cache`), `env` (`HF_HOME`, `HF_TOKEN` via `secretKeyRef`), `nodeSelector: gpu-h100-sxm`, `volumes` (`devshm` emptyDir, `hf-cache` hostPath `/mnt/hf-cache`) |
| `gpu_1_queue` / `gpu_4_queue` | `docker#v5.2.0` | `image`, `always-pull`, `propagate-environment`, `shm-size: "8gb"`, `environment: HF_HOME`, `volumes: /fsx/hf_cache` (add `HF_TOKEN` when the step needs hub access) |
**H100 kubernetes template** (copy from **Diffusion · Bagel Test** / **Diffusion · Qwen Image Test** in-tree):
```yaml
agents:
queue: "mithril-h100-pool"
plugins:
- kubernetes:
podSpec:
containers:
- image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
resources:
limits:
nvidia.com/gpu: 1
volumeMounts:
- name: devshm
mountPath: /dev/shm
- name: hf-cache
mountPath: /root/.cache/huggingface
env:
- name: HF_HOME
value: /root/.cache/huggingface
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: hf-token-secret
key: token
nodeSelector:
node.kubernetes.io/instance-type: gpu-h100-sxm
volumes:
- name: devshm
emptyDir:
medium: Memory
- name: hf-cache
hostPath:
path: /mnt/hf-cache
type: DirectoryOrCreate
```
**L4 docker template** (copy from **TTS · Qwen3-TTS Base Test** in `test-merge.yml`):
```yaml
agents:
queue: "gpu_1_queue"
plugins:
- docker#v5.2.0:
image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT
always-pull: true
propagate-environment: true
shm-size: "8gb"
environment:
- "HF_HOME=/fsx/hf_cache"
volumes:
- "/fsx/hf_cache:/fsx/hf_cache"
```
When extending an existing step (e.g. add `tests/e2e/offline_inference/test_qwen_image.py` to **Diffusion · Qwen Image Test**), update `source_file_dependencies` and `commands` only; **keep the existing `agents` + `plugins` block unchanged** unless the hardware tier changes.
The root [`.buildkite/pipeline.yml`](../../../../.buildkite/pipeline.yml) decides **which** child file is uploaded. To run **L3** on a feature branch, comment out `if: build.branch == "main"` on the merge upload step. To run **L4** without `NIGHTLY=1`, comment out the nightly upload step’s `if: build.env("NIGHTLY") == "1"` and the same `if` on relevant steps inside `test-nightly.yml`. Revert such edits before merging.
Platform-specific pipelines (e.g. AMD) follow the same level → file pairing under `.buildkite/`.
## Diffusion RFC (#1832) Alignment Tips
For **X2I(&A&T)** coverage planning:
- Prioritize high-value feature combinations with minimal case count.
- Split into lightweight L1/L2 validation plus L4 `*_expansion.py` under the **X2I** nightly shards.
- **X2V** models (Wan, HunyuanVideo) stay in the **X2V** group — do not merge into X2I sweeps.
If hardware is insufficient, provide an executable reduced case plus a deferred full CI/nightly plan.
+1
View File
@@ -68,6 +68,7 @@ Visit our [documentation](https://vllm-omni.readthedocs.io/en/latest/) to learn
- [Installation](https://vllm-omni.readthedocs.io/en/latest/getting_started/installation/)
- [Quickstart](https://vllm-omni.readthedocs.io/en/latest/getting_started/quickstart/)
- [List of Supported Models](https://vllm-omni.readthedocs.io/en/latest/models/supported_models/)
- [Deployment Recipes](https://recipes.vllm.ai) for vLLM-Omni model serving
## Contributing
+31
View File
@@ -32,6 +32,8 @@ python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
- By default, image tasks talk to `http://<host>:<port>/v1/chat/completions`; video tasks talk to `/v1/videos`.
- If you run the server on another host or port, pass `--base-url` accordingly.
- To compare diffusion multi-replica serving, restart the server with different
`runtime.num_replicas` values and keep the benchmark parameters unchanged.
## 2. Supported Datasets
@@ -93,6 +95,35 @@ You can point to your own trace using `--dataset-path`.
## 3. Benchmark Parameters
### Multi-replica sweep
For replica scaling, use the same model, prompt count, image/video size, and
step count for every run. Change only the serving stage config:
```yaml
runtime:
devices: "0,1,2,3"
num_replicas: 4
```
Then run a fixed benchmark and write one JSON file per replica count:
```bash
python3 benchmarks/diffusion/diffusion_benchmark_serving.py \
--base-url http://localhost:8099 \
--model Qwen/Qwen-Image \
--task t2i \
--dataset random \
--num-prompts 32 \
--max-concurrency 8 \
--request-rate inf \
--num-inference-steps 10 \
--width 512 --height 512 \
--output-file diffusion_replica_4.json
```
Repeat for `num_replicas=1`, `2`, `3`, and `4`, using matching `runtime.devices`.
### Basic flags
- `--base-url`: Server address; `--endpoint` selects the path appended to this base URL.
@@ -79,6 +79,7 @@ Usage:
import argparse
import ast
import asyncio
import base64
import glob
import json
import logging
@@ -106,6 +107,9 @@ from tqdm.asyncio import tqdm
logger = logging.getLogger(__name__)
_STAGE_METRICS_ENDPOINTS = {"/v1/chat/completions"}
_RETURN_STAGE_METRICS_FIELD = "return_stage_metrics"
class BaseDataset(ABC):
def __init__(self, args, api_url: str, model: str):
@@ -1013,6 +1017,73 @@ def calculate_metrics(
return metrics
def _save_generated_outputs(
outputs: list[RequestFuncOutput],
requests_list: list[RequestFuncInput],
save_dir: str,
) -> None:
"""Decode and save base64 images/videos from successful responses."""
os.makedirs(save_dir, exist_ok=True)
saved = 0
failed = 0
for idx, (req, out) in enumerate(zip(requests_list, outputs)):
if not out.success or not out.response_body:
continue
media_urls: list[str] = []
# Chat-completions style: choices[*].message.content[*].image_url.url
choices = out.response_body.get("choices", [])
if isinstance(choices, list):
for choice in choices:
content = (choice or {}).get("message", {}).get("content")
if not isinstance(content, list):
continue
for item in content:
if not isinstance(item, dict) or item.get("type") != "image_url":
continue
url = (item.get("image_url") or {}).get("url", "")
if isinstance(url, str) and url.startswith("data:"):
media_urls.append(url)
# Images endpoint style: data[*].b64_json
data_items = out.response_body.get("data", [])
if isinstance(data_items, list):
for data_item in data_items:
if not isinstance(data_item, dict):
continue
b64_json = data_item.get("b64_json", "")
if isinstance(b64_json, str) and b64_json:
media_urls.append(f"data:image/png;base64,{b64_json}")
for img_idx, url in enumerate(media_urls):
if "," not in url:
continue
try:
header, b64_data = url.split(",", 1)
ext = "png"
if "image/jpeg" in header:
ext = "jpg"
elif "image/webp" in header:
ext = "webp"
elif "video/mp4" in header:
ext = "mp4"
img_bytes = base64.b64decode(b64_data)
fname = f"req_{idx:04d}_{img_idx}.{ext}"
fpath = os.path.join(save_dir, fname)
with open(fpath, "wb") as f:
f.write(img_bytes)
saved += 1
except Exception as e:
failed += 1
logger.warning(f"Failed to save image for request {idx}: {e}", exc_info=True)
logger.info(f"Saved {saved} generated image(s) to {save_dir}. Failed to save {failed} image(s).")
def wait_for_service(base_url: str, timeout: int = 120) -> None:
print(f"Waiting for service at {base_url}...")
start_time = time.time()
@@ -1095,6 +1166,10 @@ async def benchmark(args):
requests_list = dataset.get_requests()
print(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.")
if args.return_stage_metrics and args.endpoint in _STAGE_METRICS_ENDPOINTS:
for req in requests_list:
req.extra_body.setdefault(_RETURN_STAGE_METRICS_FIELD, True)
if args.endpoint == "/v1/images/edits":
for req in requests_list:
req.default_bot_task = args.bot_task
@@ -1202,6 +1277,9 @@ async def benchmark(args):
print("\n" + "=" * 60)
if args.save_dir:
_save_generated_outputs(outputs, requests_list, args.save_dir)
if args.output_file:
with open(args.output_file, "w") as f:
json.dump(metrics, f, indent=2)
@@ -1278,11 +1356,18 @@ if __name__ == "__main__":
default=1,
help="Number of warmup requests to run before measurement.",
)
# NOTE Changed default from 1 to 2 because some models (e.g., Bagel) run
# `num_timesteps - 1` denoising iterations. A default of 1 results in 0 steps,
# which causes errors.
# TODO If this slightly longer warmup causes regression issues for other
# diffusion pipelines in the future, consider implementing model-specific
# overrides instead of a global default.
parser.add_argument(
"--warmup-num-inference-steps",
type=int,
default=1,
help="num_inference_steps used for warmup requests.",
default=2,
help="Number of inference steps used for warmup requests. "
"Default is 2 to ensure at least one denoising step is executed.",
)
parser.add_argument(
"--warmup-concurrency",
@@ -1323,6 +1408,13 @@ if __name__ == "__main__":
default=3.0,
help="SLO target multiplier: slo_ms = estimated_exec_time_ms * slo_scale (default: 3).",
)
parser.add_argument(
"--save-dir",
type=str,
default=None,
help="Directory to save generated images/outputs for visual inspection. "
"If not set, generated outputs are discarded after metric collection.",
)
parser.add_argument("--disable-tqdm", action="store_true", help="Disable progress bar.")
parser.add_argument(
"--enable-negative-prompt",
@@ -1358,6 +1450,11 @@ if __name__ == "__main__":
default="think",
help=("bot_task form field for --endpoint /v1/images/edits (think, recaption, think_recaption, vanilla)."),
)
parser.add_argument(
"--return-stage-metrics",
action="store_true",
help="Request stage duration metrics from endpoints that support return_stage_metrics.",
)
args = parser.parse_args()
asyncio.run(benchmark(args))
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+21
View File
@@ -129,6 +129,27 @@ The precheck covers five PR types: Bug Fix, Performance, New Model, Diffusion Mo
### Local Test
Please run the L1 and L2 test cases locally first and attach the results before contacting us to add the "ready" label. Please refer to the [test instructions](./ci/test_guide.md) for running the test cases.
### Automatic skip-ci (docs and pytest skip marks)
On pull requests and `main` pushes, the bootstrap step in [`.buildkite/pipeline.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/pipeline.yml) runs [`.buildkite/scripts/upload_pipeline.py`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/scripts/upload_pipeline.py) against the git diff. When every changed file qualifies, **L2 (`ready`) and L3 (`merge-test`) pipelines are not uploaded**, so the default GPU CI jobs are skipped.
| Change per file | Examples |
| --- | --- |
| Documentation | `docs/**`, any `*.md`, `mkdocs.yml` |
| Pytest skip marks only (under `tests/`) | Add/remove/edit `@pytest.mark.skip`, `@pytest.mark.skipif`, or `pytest.skip(...)`; reformat `pytestmark` only to add a skip/skipif alongside existing `pytest.mark.*` entries |
| New skipped test module | New `tests/**/*.py` whose `pytestmark` includes unconditional `pytest.mark.skip` |
These PR shapes all trigger skip-ci:
- Documentation only
- Qualifying skip-mark edits in `tests/**/*.py` only
- **A mix of documentation and qualifying skip-mark test edits**
Skip-ci does **not** apply when the diff also touches product code (for example `vllm_omni/`), or when test files change assertions, imports, fixtures, or other non-skip logic. If the diff cannot be resolved (non-PR branches outside `main`), CI runs as usual.
!!! note
Skipping L2/L3 does **not** disable the Docker image build step. Nightly (L4) upload can still run when the PR has a `nightly-test` label or on scheduled `main` builds with `NIGHTLY=1`. See [CI levels](./ci/CI_5levels.md) for how bootstrap skip-ci relates to diff-gated E2E jobs.
### Code Quality
The PR needs to meet the following code quality standards:
+54 -8
View File
@@ -11,6 +11,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
<tr>
<th>Level</th>
<th>Scope & Focus</th>
<th>Model Coverage Strategy</th>
<th>Feature Coverage Strategy</th>
<th>Interface Coverage Strategy</th>
<th>Tags</th>
<th>Time Cost</th>
<th>Test Dir</th>
<th>Doc</th>
@@ -24,6 +28,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
<td>Contribution Guideline & PR checklist</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>.github/PULL_REQUEST_TEMPLATE.md <a href="../tests_style/"> Test Style (PR Checklist)</a></td>
<td>/</td>
<td>/</td>
@@ -32,6 +40,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
<td>CI Failure Description</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td><a href="../failures/"> CI Failures</a></td>
<td>/</td>
<td>/</td>
@@ -39,6 +51,10 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
<tr>
<td><strong>L1</strong><br>(Unit & Logic)</td>
<td>Unit tests for components like entrypoints, models</td>
<td>/</td>
<td>/</td>
<td>/</td>
<td><code>core_model and cpu</code></td>
<td rowspan="2">&lt;15min</td>
<td>/tests/{component_name}/test_xxx</td>
<td>
@@ -50,10 +66,18 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
</tr>
<tr>
<td><strong>L2</strong><br>(E2E across models & GPU-required UT)</td>
<td>Online & Offline (basic deployment scenarios):<br>dummy, normal inference function (output format, stream), some instance startup UT</td>
<td>Online (basic deployment scenarios):<br>dummy, normal inference function (output format, stream), some instance startup UT</td>
<td>High-priority models + online basic scenarios + request success validation</td>
<td>High-priority features (using random lightweight models)</td>
<td>High-priority interfaces (using random lightweight models)</td>
<td><code>core_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
<td>
<strong>Model tests:</strong><br>
/tests/e2e/online_serving/test_{model_name}.py<br>
/tests/e2e/offline_inference/test_{model_name}.py
<strong>Feature tests:</strong><br>
/tests/{component_name}/test_xxx<br>
<strong>Interface tests:</strong><br>
/tests/entrypoints/test_xxx
</td>
<td>
<a href="#chapter-1-l1-l2-level-testing-unit-testing-and-basic-end-to-end-verification">Chapter 1</a><br>
@@ -65,10 +89,19 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
<tr>
<td><strong>L3</strong><br>(Important Perf & Integration & Accuracy)</td>
<td>Online & Offline (multiple deployment scenarios):<br>real model, normal inference function, normal accuracy</td>
<td>High/medium-priority models with real weights + online/offline key scenarios + basic accuracy validation</td>
<td>Medium-priority features (using random lightweight models)</td>
<td>Medium-priority interfaces (using random lightweight models)</td>
<td><code>advanced_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
<td>&lt;30min</td>
<td>
<strong>Model tests:</strong><br>
/tests/e2e/online_serving/test_{model_name}.py<br>
/tests/e2e/offline_inference/test_{model_name}.py
/tests/e2e/offline_inference/test_{model_name}.py<br>
<strong>Feature tests:</strong><br>
/tests/{component_name}/test_xxx<br>
<strong>Interface tests:</strong><br>
/tests/entrypoints/test_xxx
</td>
<td>
<a href="#chapter-2-l3-level-testing-core-integration-performance-and-accuracy-verification">Chapter 2</a><br>
@@ -79,18 +112,27 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
</tr>
<tr>
<td><strong>L4</strong><br>(Perf & Integration & Accuracy)</td>
<td>Online & Offline: full functional scenarios + performance test + doc test</td>
<td>Online: full functional scenarios + performance test + doc test + accuracy test</td>
<td>High-priority models: function, performance, accuracy, and doc testing<br>Medium/low-priority models: function and doc testing</td>
<td>Low-priority features (using random lightweight models)</td>
<td>Low-priority interfaces (using random lightweight models)</td>
<td><code>full_model and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
<td>&lt;3 hour</td>
<td>
<strong>Full Function:</strong><br>
<strong>Model tests:</strong><br>
/tests/e2e/online_serving/test_{model_name}_expansion.py<br>
/tests/e2e/offline_inference/test_{model_name}_expansion.py<br>
<strong>Feature tests:</strong><br>
/tests/{component_name}/test_xxx<br>
<strong>Interface tests:</strong><br>
/tests/entrypoints/test_xxx<br>
<strong>Performance:</strong><br>
/tests/dfx/perf/tests/test_qwen_omni.json (Omni), test_tts.json (TTS),<br>
and /tests/dfx/perf/tests/test_{diffusion_model}_vllm_omni.json (Diffusion)<br>
<strong>Doc Test:</strong><br>
tests/example/online_serving/test_{model_name}.py<br>
tests/example/offline_inference/test_{model_name}.py
tests/example/offline_inference/test_{model_name}.py<br>
<strong>Accuracy Test:</strong><br>
/tests/e2e/accuracy/test_{model_name}.py
</td>
<td>
<a href="#chapter-3-l4-level-testing-full-functionality-performance-and-documentation-testing">Chapter 3</a><br>
@@ -101,7 +143,11 @@ Through five levels (L1-L5) and common (Common) specifications, the system clari
</tr>
<tr>
<td><strong>L5</strong><br>(Stability & Reliability)</td>
<td>Online & Offline: long-term stability test + reliability test</td>
<td>Online: long-term stability test + reliability test</td>
<td>Long-term stability and reliability testing for high-priority models</td>
<td>/</td>
<td>Invalid-parameter validation for high-priority interfaces</td>
<td><code>slow and hardware_test(H100, L4, etc.) and omni/tts/diffusion</code></td>
<td> Depends on reality </td>
<td>
<strong>Stability:</strong><br>
File diff suppressed because it is too large. Load diff
+4
View File
@@ -94,6 +94,10 @@ Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc
### Build your own docker image
=== "NVIDIA CUDA"
--8<-- "docs/getting_started/installation/gpu/cuda.inc.md:build-docker"
=== "AMD ROCm"
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:build-docker"
@@ -110,3 +110,53 @@ docker run --runtime nvidia --gpus 2 \
The CUDA image does not define a default entrypoint, so include `vllm serve ... --omni` after the image name.
# --8<-- [end:pre-built-images]
# --8<-- [start:build-docker]
#### Build docker image
```bash
DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.cuda -t vllm-omni-cuda .
```
If you want to specify the base vLLM version:
```bash
DOCKER_BUILDKIT=1 docker build \
-f docker/Dockerfile.cuda \
--build-arg BASE_IMAGE=vllm/vllm-openai:v0.22.1 \
-t vllm-omni-cuda .
```
#### Launch the docker image
##### Launch with OpenAI API Server
!!! note
The model `Qwen/Qwen3-Omni-30B-A3B-Instruct` requires significant GPU memory. The example below has been verified on 2 x H100's.
```bash
docker run --runtime nvidia --gpus 2 \
-v ${HF_HOME:-$HOME/.cache/huggingface}:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \
--ipc=host \
vllm-omni-cuda \
vllm serve --omni --model Qwen/Qwen3-Omni-30B-A3B-Instruct --port 8091
```
By default, this mounts `$HOME/.cache/huggingface` as the model cache directory. To use a custom location, set the `HF_HOME` environment variable before running the command (e.g., `export HF_HOME=/data/models`).
##### Launch with interactive session for development
```bash
docker run --runtime nvidia --gpus all -it --rm \
-v ${HF_HOME:-$HOME/.cache/huggingface}:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \
-p 8091:8091 \
--ipc=host \
--entrypoint bash \
vllm-omni-cuda
```
# --8<-- [end:build-docker]
+6 -1
View File
@@ -6,6 +6,8 @@ vLLM-Omni supports unified multimodal comprehension and generation models across
If vLLM-Omni natively supports a model, its implementation can be found in <gh-file:vllm_omni/model_executor/models> and <gh-file:vllm_omni/diffusion/models>.
For deployment recipes (TTS and multimodal), see [recipes.vllm.ai](https://recipes.vllm.ai) and the in-repo [`recipes/`](https://github.com/vllm-project/vllm-omni/tree/main/recipes) directory.
## List of Supported Models
<style>
@@ -22,6 +24,7 @@ th {
| `MingFlashOmniForConditionalGeneration` + `MingImagePipeline` | Ming-flash-omni-2.0 (omni-speech + imagegen<sup>1</sup>) | `Jonathan1909/Ming-flash-omni-2.0` | ✅︎ | | | |
| `BagelForConditionalGeneration` | BAGEL (DiT-only) | `ByteDance-Seed/BAGEL-7B-MoT` | ✅︎ | ✅︎ | | ✅︎ |
| `InternVLAA1Pipeline` | InternVLA-A1 | `InternRobotics/InternVLA-A1-3B` | ✅︎ | ✅︎ | | |
| `Gr00tN1d7Pipeline` | GR00T N1.7 | `nvidia/GR00T-N1.7-3B` | ✅︎ | | | |
| `HunyuanImage3ForCausalMM` | HunyuanImage3.0 (DiT-only) | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `QwenImagePipeline` | Qwen-Image | `Qwen/Qwen-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `QwenImagePipeline` | Qwen-Image-2512 | `Qwen/Qwen-Image-2512` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
@@ -48,6 +51,7 @@ th {
| `OvisImagePipeline` | Ovis-Image | `OvisAI/Ovis-Image` | ✅︎ | ✅︎ | | ✅︎ |
| `LongcatImagePipeline` | LongCat-Image | `meituan-longcat/LongCat-Image` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ |
| `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ |
| `OmniVoiceModel` | OmniVoice | `k2-fsa/OmniVoice` | ✅︎ | | | |
@@ -62,13 +66,14 @@ th {
| `AudioXPipeline` | AudioX | `zhangj1an/AudioX` | ✅︎ | ✅︎ | | |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-CustomVoice | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-VoiceDesign | `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `Qwen3TTSForConditionalGeneration` | Qwen3-TTS-12Hz-1.7B-Base | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | ✅︎ | ✅︎ | ✅︎ | ✅︎ |
| `MingTTSForConditionalGeneration` | Ming-omni-tts dense 0.5B | `inclusionAI/Ming-omni-tts-0.5B` | ✅︎ | | | |
| `GLMTTSForConditionalGeneration` | GLM-TTS | `zai-org/GLM-TTS` | ✅︎ | | | |
| `MossTTSNanoForCausalLM` | MOSS-TTS-Nano | `OpenMOSS-Team/MOSS-TTS-Nano` | ✅︎ | | | |
| `MossTTSDelayModel` | MOSS-TTS, MOSS-TTSD, MOSS-SoundEffect, MOSS-VoiceGenerator | `OpenMOSS-Team/MOSS-VoiceGenerator` | ✅︎ | | | |
| `MossTTSRealtime` | MOSS-TTS-Realtime | `OpenMOSS-Team/MOSS-TTS-Realtime` | ✅︎ | | | |
| `HiggsAudioV2ForConditionalGeneration` | Higgs-Audio v2 | `bosonai/higgs-audio-v2-generation-3B-base` | ✅︎ | | | |
| `HiggsMultimodalQwen3ForConditionalGeneration` | Higgs-Audio v3 (TTS) | `bosonai/higgs-audio-v3-tts-4b` | ✅︎ | | | |
| `NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | ✅︎ | ✅︎ | | ✅︎ |
| `MiMoAudioModel` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | ✅︎ | ✅︎ | | |
| `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | |
+7
View File
@@ -9,6 +9,13 @@ vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation.
See the [Supported Models](#supported-models) section below for the full list, including OmniVoice, VoxCPM2, and MOSS-TTS-Nano.
!!! tip "Deployment recipes"
TTS deployment recipes are published at
[recipes.vllm.ai](https://recipes.vllm.ai) (e.g.
[Qwen3-TTS](https://recipes.vllm.ai/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice),
[Higgs-Audio v3](https://recipes.vllm.ai/bosonai/higgs-audio-v3-tts-4b)).
The in-repo runbooks live under [`recipes/`](https://github.com/vllm-project/vllm-omni/tree/main/recipes).
Each server instance runs a single model (specified at startup via `vllm serve <model> --omni`).
## Quick Start
+5 -2
View File
@@ -195,13 +195,16 @@ curl -X POST http://localhost:8091/v1/videos/sync \
## Storage
Set `VLLM_OMNI_STORAGE_PATH` to control where asynchronous video outputs are
Set `VLLM_OMNI_SERVER_STORAGE__PATH` to control where asynchronous video outputs are
stored:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
```
> `VLLM_OMNI_STORAGE_PATH` is deprecated and will be removed in a future release;
> use `VLLM_OMNI_SERVER_STORAGE__PATH` instead.
## Model-Specific Examples
For complete text-to-video, image-to-video, and model-specific video-to-video
@@ -193,6 +193,7 @@ Factory function `get_offload_backend()` selects the appropriate backend based o
| NextStep11Pipeline | `stepfun-ai/NextStep-1.1` | `NextStepModel` | - | ✓ | `"layers"` |
| OvisImagePipeline | `AIDC-AI/Ovis-Image-7B` | `OvisImageTransformer2DModel` | - | ✓ | `"transformer"` |
| QwenImagePipeline | `Qwen/Qwen-Image` | `QwenImageTransformer2DModel` | ✓ | ✓ | `"transformer_blocks"` |
| StableDiffusionXLPipeline | `stabilityai/stable-diffusion-xl-base-1.0` | `SDXLUNet2DConditionModel` | ✓ | ✓ | `"down_blocks"`, `"up_blocks"` |
| StableDiffusion3Pipeline | `stabilityai/stable-diffusion-3.5-medium` | `SD3Transformer2DModel` | - | ✓ | `"transformer_blocks"` |
| Wan22I2VPipeline | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
| Wan22Pipeline | `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | `WanTransformer3DModel` | ✓ | ✓ | `"blocks"` |
+50
View File
@@ -0,0 +1,50 @@
This directory contains auto-tuned Triton kernel configurations for the
MoT (Mixture-of-Tokens) GEMM and RMSNorm operators used by BAGEL and other
MoT-architecture diffusion models.
File naming convention:
device_name=<GPU_NAME>,dtype=<DTYPE>.json
For example:
device_name=NVIDIA_A100-SXM4-80GB,dtype=w16a16.json
Each JSON file maps (K, N) matrix shapes to a dictionary of batch sizes (M)
and their optimal Triton tile configurations:
{
"3584_4608": { // K=3584, N=4608 (QKV projection)
"1024": {
"BLOCK_SIZE_M": 64,
"BLOCK_SIZE_N": 128,
"BLOCK_SIZE_K": 64,
"GROUP_SIZE_M": 8,
"num_warps": 4,
"num_stages": 3
},
...
},
"3584_3584": { ... } // K=3584, N=3584 (output projection)
}
Config loading order (3-tier, see ops/mot_gemm.py):
1. $VLLM_TUNED_CONFIG_FOLDER/<filename> (env override)
2. This directory: vllm_omni/diffusion/layers/mot/configs/<filename>
3. Conservative default config (compiles everywhere, sub-optimal perf)
If no config file matches the current device, a warning is printed with
instructions to run the auto-tuning benchmark.
To generate configs for your hardware:
python benchmarks/kernels/mot_linear_benchmarks.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--tp-size 1 --dtype w16a16 --tune \
--save-dir vllm_omni/diffusion/layers/mot/configs/
For multi-GPU tuning (uses Ray for parallel search):
python benchmarks/kernels/mot_linear_benchmarks.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--tp-size 2 --tune
See benchmarks/kernels/mot_linear_benchmarks.py for full options.
@@ -0,0 +1,80 @@
# Diffusion Multi-Replica Serving
Diffusion stages can be replicated in online serving by setting `runtime.num_replicas` on a `stage_type: diffusion` stage. Each replica owns an independent diffusion engine process and receives requests through round-robin routing with request affinity.
## When to Use
Use multi-replica serving when one diffusion process does not saturate the available GPUs, or when you want higher request throughput for many independent image/video generation requests. It improves concurrency throughput; it does not make a single request faster.
## Configuration
A replica consumes the devices required by that diffusion stage. For single-device diffusion pipelines, set `runtime.devices` to one device per replica:
```yaml
stages:
- stage_id: 0
stage_type: diffusion
runtime:
devices: "0,1,2,3"
num_replicas: 4
engine_args:
model: Qwen/Qwen-Image
parallel_config:
world_size: 1
```
For multi-device diffusion pipelines, `parallel_config.world_size` is treated as the number of devices per replica. For example, four 2-GPU replicas need eight devices:
```yaml
stages:
- stage_id: 0
stage_type: diffusion
runtime:
devices: "0,1,2,3,4,5,6,7"
num_replicas: 4
engine_args:
model: Wan-AI/Wan2.2-T2V-A14B-Diffusers
parallel_config:
world_size: 2
```
The runtime splits the device list into contiguous chunks per replica. With the second example, replica 0 uses `0,1`, replica 1 uses `2,3`, replica 2 uses `4,5`, and replica 3 uses `6,7`.
## Serving
Start the server with the stage config:
```bash
vllm serve Qwen/Qwen-Image \
--omni \
--port 8099 \
--stage-configs-path /path/to/diffusion_multi_replica.yaml
```
Then send normal OpenAI-compatible diffusion chat requests. The orchestrator routes new requests across replicas and keeps updates/abort calls on the same replica for each request.
## Benchmarking
Benchmark each replica count by changing `runtime.num_replicas` and `runtime.devices`, restarting the server, then running:
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--base-url http://localhost:8099 \
--model Qwen/Qwen-Image \
--task t2i \
--dataset random \
--num-prompts 8 \
--request-rate inf \
--num-inference-steps 2 \
--width 256 --height 256 \
--output-file diffusion_replica_4.json
```
For a quick scaling sweep, test `num_replicas` values `1`, `2`, `3`, and `4` with the same prompt count and generation parameters. Compare throughput and end-to-end latency from the JSON outputs.
## Notes and Limitations
- Multi-replica serving is currently supported for diffusion stages. Single-stage mode still rejects non-diffusion replicas.
- `runtime.devices` must provide exactly `num_replicas * devices_per_replica` devices.
- Request-level latency may stay similar or slightly increase because each request still runs on one replica.
- Throughput improves only when enough concurrent requests are issued and each replica has enough GPU memory.
+1
View File
@@ -130,6 +130,7 @@ The following tables show which models support each feature:
| **Qwen-Image-Edit-2509** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ (decode) | ✅ | ❌ | ❌ |
| **Qwen-Image-Layered** | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **SenseNova-U1** | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Stable-Diffusion-XL** | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Stable-Diffusion3.5** | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ (decode) | ❌ | ❌ |
| **Z-Image** | ✅ | ✅ | ✅ | ❓ | ✅ (TP=2 only) | ❌ | ✅ | ❌ | ✅ (decode) | ✅ | ❌ |
| **ERNIE-Image** | ❌ | ✅ | ✅ | ❓ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
@@ -19,7 +19,7 @@ both stages on a single GPU, override per stage:
```bash
vllm serve zai-org/GLM-Image --omni --port 8091 \
--stage-0-devices 0 --stage-1-devices 0
--stage-overrides '{"0": {"devices": "0"}, "1": {"devices": "0"}}'
```
## API Calls
@@ -84,16 +84,26 @@ curl -X POST http://localhost:8091/v1/videos/sync \
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files
(default: `/tmp/storage`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete/open
operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_TTL`: optional TTL for generated files in
seconds
- `VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL`: optional sweep interval in
seconds for enforcing file TTL; defaults to `300` when TTL is set
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__FILE_TTL=86400
export VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL=300
```
See also: [Configuration Options](../../../configuration/README.md)
## API Calls
### Method 1: Using curl
@@ -90,16 +90,26 @@ curl -X POST http://localhost:8091/v1/videos/sync \
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files
(default: `/tmp/storage`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete/open
operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_TTL`: optional TTL for generated files in
seconds
- `VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL`: optional sweep interval in
seconds for enforcing file TTL; defaults to `300` when TTL is set
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__FILE_TTL=86400
export VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL=300
```
See also: [Configuration Options](../../../configuration/README.md)
## API Calls
### Method 1: Using curl
+1
View File
@@ -85,6 +85,7 @@ warmup_quack_fp8([(14040, 2048, 6144), (14040, 2048, 2048)])
| FLUX.2-klein | `black-forest-labs/FLUX.2-klein-4B` | Yes | Yes | All layers | None | |
| HunyuanImage-3.0 | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | Yes | Yes | All layers; use the Hunyuan stage config for multi-stage runs | None | |
| HunyuanVideo-1.5 | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `720p_t2v`, `480p_i2v` | Yes | Yes | All layers | None | |
| Cosmos3 | `nvidia/Cosmos3-Nano` | Yes | Not validated | All layers | None | |
### Multi-Stage Omni/TTS Model (Qwen3-Omni, Qwen3-TTS)
+43
View File
@@ -89,11 +89,54 @@ and
| Model | Scope | Status |
|-------|-------|--------|
| Qwen3-Omni | Thinker language-model stage | ModelOpt FP8 checkpoint path |
| Qwen3-Omni | Thinker language-model stage (W4A4 NVFP4) | Validated; see [Qwen3-Omni NVFP4 W4A4](#qwen3-omni-nvfp4-w4a4-thinker) below |
| Qwen3-TTS | TTS language-model stage | Not validated |
Audio encoder, vision encoder, talker, and code2wav stages stay in BF16 unless
a model-specific guide documents otherwise.
#### Qwen3-Omni NVFP4 W4A4 (thinker)
vLLM-Omni serves ModelOpt NVFP4 W4A4 quantizations of the
Qwen3-Omni-30B-A3B-Instruct thinker language model. The thinker text body
(attention + MoE experts) is quantized to NVFP4 with FP8 per-tensor input
scales; the audio encoder, vision encoder, talker, and code2wav stay in BF16.
| Variant | HF checkpoint | Hardware |
|---------|---------------|----------|
| W4A4 NVFP4 (full thinker) | `YihongJin/Qwen3-Omni-30B-A3B-Instruct-NVFP4-W4A4-full-thinker-awqclip` | sm_100+ (Blackwell, FlashInfer FP4 GEMM) |
Calibration uses ModelOpt `mtq.NVFP4_DEFAULT_CFG` with the `awq_clip` algorithm
on 1024 ultrachat samples chat-templated through the Qwen3-Omni tokenizer.
Excluded modules: `*audio_tower*`, `*visual*`, `*talker*`, `*code2wav*`,
`*lm_head*`, `*mlp.gate*`. See `scripts/nvfp4/calibrate.py` for the reference
recipe.
!!! note "ModelOpt 0.44 NaN regression workaround"
ModelOpt 0.44's float32 -> FP8 E4M3 cast of per-block weight scales
occasionally emits literal NaN bytes (E4M3 encoding 0x7F / 0xFF) for
blocks whose pre-cast scale rounds above the FP8 max of 448 after the
global-scale division. A single NaN byte in any `weight_scale`
propagates through the FP4 GEMM and collapses the served model output
to `!!!!`. vLLM-Omni's `vllm_omni.patch` installs a defensive override
of `ModelOptNvFp4LinearMethod.process_weights_after_loading` that
clamps these bytes to the FP8 E4M3 max at load time. The override
self-extinguishes once vllm-omni's vllm pin moves to a release
containing the upstream fix. Set `VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP=1`
to disable the override for diagnostics.
Serving:
```bash
vllm serve YihongJin/Qwen3-Omni-30B-A3B-Instruct-NVFP4-W4A4-full-thinker-awqclip \
--omni --port 8000
```
> Do **not** pass `--enforce-eager` for production / benchmarks. CUDA graphs
> amortize launch overhead and unlock the FP4 throughput wins; with
> `--enforce-eager` set, W4A4 TPOT degrades ~10x relative to the CUDA-graph
> configuration.
### Multi-Stage Diffusion Model
ModelOpt checkpoints must be routed to the stage whose checkpoint contains the
-391
View File
@@ -1,391 +0,0 @@
# BAGEL-7B-MoT
## Setup
Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup.
## Architecture
BAGEL-7B-MoT is a Mixture-of-Transformers (MoT) model supporting both image generation and understanding. It offers two deployment topologies:
| Topology | Stages | Description |
| :------- | :----- | :---------- |
| **Two-stage** (default) | Stage 0 (Thinker, AR) + Stage 1 (DiT, Diffusion) | Thinker handles text/understanding via vLLM AR engine; DiT handles image generation. KV cache is transferred between stages. |
| **Single-stage** | Stage 0 (DiT, Diffusion) only | The DiT stage contains a full LLM, ViT, VAE, and tokenizer internally. All modalities are handled within a single diffusion process. |
Both topologies support all four modalities: `text2img`, `img2img`, `img2text`, `text2text`.
## Quick Start
```bash
cd examples/offline_inference/bagel
# Default two-stage mode (auto-detected)
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat"
# Single-stage mode
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat" \
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
```
> **Note**: These examples work with the default configuration on an **NVIDIA A100 (80GB)**. For dual-GPU setups, modify the deploy YAML to distribute stages across devices.
## Modality Control
Control the mode using the `--modality` argument:
| Modality | Input | Output | Description |
| :------- | :---- | :----- | :---------- |
| `text2img` | Text | Image | Generate images from text prompts |
| `img2img` | Image + Text | Image | Transform images using text guidance |
| `img2text` | Image + Text | Text | Generate text descriptions from images |
| `text2text` | Text | Text | Pure text generation (language model mode) |
### Text to Image (text2img)
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat" \
--steps 50
```
### Image to Image (img2img)
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality img2img \
--image-path /path/to/image.jpg \
--prompts "Let the woman wear a blue dress"
```
### Image to Text (img2text)
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality img2text \
--image-path /path/to/image.jpg \
--prompts "Describe this image in detail"
```
### Text to Text (text2text)
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2text \
--prompts "What is the capital of France?"
# Load prompts from a text file (one prompt per line):
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2text \
--txt-prompts /path/to/prompts.txt
```
## Think Mode
Think mode enables the model to generate `<think>...</think>` planning/reasoning tokens before producing the final output. This improves generation quality for complex prompts.
- **Two-stage**: The Thinker (AR) stage decodes think tokens, then transfers the augmented KV cache to the DiT stage for image generation.
- **Single-stage**: The DiT's internal LLM generates think tokens in-place before proceeding to denoise.
```bash
# Think + text2img: plan before generating
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A futuristic city with flying cars" \
--think \
--max-think-tokens 1000
# Think + img2img: reason about the edit
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality img2img \
--image-path /path/to/image.jpg \
--prompts "Make it look like a watercolor painting" \
--think
# Think + img2text: reason before describing
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality img2text \
--image-path /path/to/image.jpg \
--prompts "What is happening in this image?" \
--think
# Think + text2text: chain-of-thought reasoning
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2text \
--prompts "Solve: 23 * 47" \
--think
```
Think mode parameters:
| Argument | Default | Description |
| :------- | :------ | :---------- |
| `--think` | `False` | Enable thinking mode |
| `--max-think-tokens` | `1000` | Maximum tokens for think generation |
| `--do-sample` | `False` | Enable sampling (vs. greedy) for text generation |
| `--text-temperature` | `0.3` | Temperature for text generation sampling |
## Classifier-Free Guidance (CFG)
CFG controls the trade-off between prompt fidelity and diversity. These parameters apply to image generation modalities (`text2img`, `img2img`).
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A photorealistic portrait" \
--cfg-text-scale 6.0 \
--cfg-img-scale 2.0 \
--negative-prompt "blurry, low quality, distorted" \
--cfg-interval 0.4 1.0 \
--cfg-renorm-type global \
--cfg-renorm-min 0.0
```
| Argument | Default | Description |
| :------- | :------ | :---------- |
| `--cfg-text-scale` | `4.0` | Text CFG scale (higher = more prompt-adherent) |
| `--cfg-img-scale` | `1.5` | Image CFG scale (for img2img) |
| `--negative-prompt` | `None` | Negative prompt for CFG conditioning |
| `--cfg-interval` | pipeline default | CFG active interval `[start, end]` as fractions of total timesteps |
| `--cfg-renorm-type` | `None` | Renormalization type: `global`, `text_channel`, `channel` |
| `--cfg-renorm-min` | `None` | Minimum renormalization value |
| `--cfg-parallel-size` | `1` | CFG parallel size: `1` = batched (single GPU), `2` = 2-branch parallel, `3` = full 3-GPU parallel |
## Deployment Topologies
### Two-Stage (Default)
The default topology auto-detected from the model. No extra flags needed.
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat"
```
The pipeline is defined in [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml). Stage 0 (Thinker) and Stage 1 (DiT) share GPU 0 by default. For dual-GPU setups, customize the deploy YAML and set `devices: "1"` for stage 1.
### Single-Stage
Pass the single-stage deploy config via `--deploy-config`:
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat" \
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
```
See [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) for configuration details. The `pipeline: bagel_single_stage` field selects the single-stage topology from the pipeline registry.
### Tensor Parallelism (TP)
For larger models or multi-GPU environments, customize the deploy YAML (see [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)) and set per-stage `tensor_parallel_size` and `devices`:
```yaml
# Example: TP=2 on GPUs 0,1 for the Thinker stage
stages:
- stage_id: 0
tensor_parallel_size: 2
devices: "0,1"
```
### VAE Patch Parallelism
[VAE Patch Parallelism](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism/vae_patch_parallel.html) splits Bagel VAE **decode/encode** tiles across multiple GPUs on the **DiT stage**, reducing **per-GPU peak memory during VAE decode**. Use it when high-resolution `text2img` or `img2img` hits VAE OOM or large decode spikes.
**Bagel-specific notes:**
- Implemented in `BagelPipeline` via `DistributedAutoEncoder` (DiT stage only).
- **Single-stage** is the simplest path: one DiT process with TP + VAE patch parallel.
- **Two-stage**: enable on **stage 1 (DiT)** only; stage 0 (Thinker) keeps encoder-only `VAEEncoder` and does not use VAE patch parallel.
- You need a DiT `world_size` ≥ `vae_patch_parallel_size` (typically `tensor_parallel_size=2` on that stage). VAE PP reuses the DiT process group; it is not a standalone second-GPU VAE worker.
**Single-stage via deploy YAML** (recommended for `end2end.py`):
```yaml
pipeline: bagel_single_stage
async_chunk: false
stages:
- stage_id: 0
max_num_batched_tokens: 32768
max_num_seqs: 1
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
devices: "0,1"
vae_use_tiling: true
parallel_config:
tensor_parallel_size: 2
vae_patch_parallel_size: 2
default_sampling_params:
seed: 52
```
```bash
cd examples/offline_inference/bagel
CUDA_VISIBLE_DEVICES=0,1 python end2end.py \
--model /path/to/BAGEL-7B-MoT \
--deploy-config /path/to/bagel_single_stage_vae_pp.yaml \
--modality text2img \
--prompts "A cute cat" \
--steps 10 \
--output ./out_vae_pp
```
**Single-stage via `Omni` kwargs** (same flags as online serving):
```python
from vllm_omni.entrypoints.omni import Omni
omni = Omni(
model="ByteDance-Seed/BAGEL-7B-MoT",
deploy_config="vllm_omni/deploy/bagel_single_stage.yaml",
tensor_parallel_size=2,
vae_patch_parallel_size=2,
vae_use_tiling=True,
)
# Then call omni.generate(...) as in end2end.py
```
**Two-stage (VAE PP on DiT only):**
```yaml
stages:
- stage_id: 0
devices: "0"
# AR Thinker — no vae_patch_parallel here
- stage_id: 1
devices: "0,1"
vae_use_tiling: true
parallel_config:
tensor_parallel_size: 2
vae_patch_parallel_size: 2
```
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--deploy-config /path/to/bagel_vae_pp.yaml \
--modality text2img \
--prompts "A cute cat"
```
**Startup log checks:**
```text
INFO ... vae_patch_parallel_size=2 requires vae_use_tiling; automatically enabling it.
```
| Setting | Role |
| :------ | :--- |
| `parallel_config.tensor_parallel_size` | DiT world size / TP (must be ≥ `vae_patch_parallel_size`) |
| `parallel_config.vae_patch_parallel_size` | Number of ranks for distributed VAE tiles (`1` = off) |
| `vae_use_tiling` | Enable spatial tiling (auto-enabled when `vae_patch_parallel_size > 1`) |
#### Hybrid Sharded Data Parallel (HSDP)
For larger Bagel deployments on multiple GPUs, you can enable HSDP (Hybrid Sharded Data Parallel) by modifying the stage configuration (for example, [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)). HSDP shards transformer weights across GPUs to reduce per-GPU memory usage.
1. **Enable HSDP**: Set `use_hsdp: true`.
2. **Set shard size**: Set `hsdp_shard_size` to the number of GPUs used for sharding (for example, `4`).
3. **Set replicate size**: Usually keep `hsdp_replicate_size: 1` unless you want replicated HSDP groups.
4. **Set devices**: Specify the comma-separated GPU IDs used by the diffusion stage (for example, `"0,1,2,3"`).
Example configuration for HSDP across 4 GPUs:
```yaml
- stage_id: 1
devices: "0,1,2,3"
parallel_config:
use_hsdp: true
hsdp_shard_size: 4
hsdp_replicate_size: 1
```
Then pass the custom deploy YAML:
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat" \
--deploy-config /path/to/custom_bagel.yaml
```
### FP8 Quantization
```bash
python end2end.py --model ByteDance-Seed/BAGEL-7B-MoT \
--modality text2img \
--prompts "A cute cat" \
--quantization fp8
```
## Command Line Reference
### Core Arguments
| Argument | Type | Default | Description |
| :------- | :--- | :------ | :---------- |
| `--model` | string | `ByteDance-Seed/BAGEL-7B-MoT` | Model path or HuggingFace name |
| `--modality` | choice | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` |
| `--prompts` | list | `None` | Input text prompts |
| `--txt-prompts` | string | `None` | Path to text file with one prompt per line |
| `--image-path` | string | `None` | Input image path (required for `img2img`/`img2text`) |
| `--output` | string | `.` | Output directory for saved images |
| `--steps` | int | `50` | Number of diffusion inference steps |
| `--seed` | int | `None` | Random seed for reproducibility |
### Think Mode Arguments
| Argument | Type | Default | Description |
| :------- | :--- | :------ | :---------- |
| `--think` | flag | `False` | Enable `<think>...</think>` planning/reasoning |
| `--max-think-tokens` | int | `1000` | Maximum tokens for think generation |
| `--do-sample` | flag | `False` | Use sampling instead of greedy decoding |
| `--text-temperature` | float | `0.3` | Sampling temperature for text generation |
### CFG Arguments
| Argument | Type | Default | Description |
| :------- | :--- | :------ | :---------- |
| `--cfg-text-scale` | float | `4.0` | Text CFG guidance scale |
| `--cfg-img-scale` | float | `1.5` | Image CFG guidance scale |
| `--negative-prompt` | string | `None` | Negative prompt for CFG |
| `--cfg-parallel-size` | int | `1` | CFG parallel GPU count (1, 2, or 3) |
| `--cfg-interval` | float[2] | pipeline default | CFG active window `[start, end]` |
| `--cfg-renorm-type` | string | `None` | `global`, `text_channel`, or `channel` |
| `--cfg-renorm-min` | float | `None` | Minimum renormalization value |
### Engine Arguments
| Argument | Type | Default | Description |
| :------- | :--- | :------ | :---------- |
| `--deploy-config` | string | `None` | Path to deploy YAML (auto-detected if omitted) |
| `--worker-backend` | choice | `process` | `process` or `ray` |
| `--ray-address` | string | `None` | Ray cluster address |
| `--quantization` | string | `None` | Quantization method (e.g. `fp8`) |
| `--log-stats` | flag | `False` | Enable statistics logging |
| `--init-timeout` | int | `300` | Initialization timeout (seconds) |
| `--batch-timeout` | int | `5` | Batch timeout (seconds) |
| `--enable-diffusion-pipeline-profiler` | flag | `False` | Profile diffusion stage durations |
## FAQ
- If you encounter OOM errors, try decreasing `max_model_len` or `gpu_memory_utilization` in the deploy YAML.
**Two-stage VRAM usage:**
| Stage | VRAM |
| :---- | :--- |
| Stage 0 (Thinker) | **15.04 GiB + KV Cache** |
| Stage 1 (DiT) | **26.50 GiB** |
| Total | **~42 GiB + KV Cache** |
**Single-stage VRAM usage:** The DiT loads the full model (~42 GiB) in one process.
-278
View File
@@ -1,278 +0,0 @@
import argparse
import os
from vllm_omni.inputs.data import OmniPromptType
from vllm_omni.model_executor.stage_input_processors.bagel import (
GEN_THINK_SYSTEM_PROMPT,
VLM_THINK_SYSTEM_PROMPT,
)
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"--model",
default="ByteDance-Seed/BAGEL-7B-MoT",
help="Path to merged model directory.",
)
parser.add_argument("--prompts", nargs="+", default=None, help="Input text prompts.")
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one prompt per line (preferred).",
)
parser.add_argument("--prompt-type", default="text", choices=["text"])
parser.add_argument(
"--modality",
default="text2img",
choices=["text2img", "img2img", "img2text", "text2text"],
help="Modality mode to control stage execution.",
)
parser.add_argument(
"--image-path",
type=str,
default=None,
help="Path to input image for img2img.",
)
parser.add_argument(
"--output",
type=str,
default=".",
help="Output directory to save images.",
)
# OmniLLM init args
parser.add_argument("--log-stats", action="store_true", default=False)
parser.add_argument("--init-sleep-seconds", type=int, default=20)
parser.add_argument("--batch-timeout", type=int, default=5)
parser.add_argument("--init-timeout", type=int, default=300)
parser.add_argument("--shm-threshold-bytes", type=int, default=65536)
parser.add_argument("--worker-backend", type=str, default="process", choices=["process", "ray"])
parser.add_argument("--ray-address", type=str, default=None)
parser.add_argument(
"--deploy-config",
type=str,
default=None,
help="Path to deploy YAML. If unset, auto-loads vllm_omni/deploy/bagel.yaml based on the HF model_type.",
)
parser.add_argument("--steps", type=int, default=50, help="Number of inference steps.")
parser.add_argument("--cfg-text-scale", type=float, default=4.0, help="Text CFG scale (default: 4.0)")
parser.add_argument("--cfg-img-scale", type=float, default=1.5, help="Image CFG scale (default: 1.5)")
parser.add_argument(
"--negative-prompt", type=str, default=None, help="Negative prompt for CFG (default: empty prompt)"
)
parser.add_argument(
"--cfg-parallel-size",
type=int,
default=1,
choices=[1, 2, 3],
help="CFG parallel size: 1=batched (single GPU), 2=parallel with 2 branches (text CFG only), 3=parallel (3 GPUs).",
)
parser.add_argument("--seed", type=int, default=None, help="Random seed for generation.")
parser.add_argument(
"--cfg-interval",
type=float,
nargs=2,
default=None,
help="CFG interval [start, end] (default: pipeline default)",
)
parser.add_argument(
"--cfg-renorm-type", type=str, default=None, help="CFG renorm type: global, text_channel, channel"
)
parser.add_argument("--cfg-renorm-min", type=float, default=None, help="CFG renorm min")
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
parser.add_argument(
"--quantization",
type=str,
default=None,
help="Quantization method (e.g. 'fp8').",
)
parser.add_argument(
"--think",
action="store_true",
default=False,
help="Enable thinking mode: AR stage decodes <think>...</think> planning tokens before image generation.",
)
parser.add_argument(
"--max-think-tokens",
type=int,
default=1000,
help="Maximum number of tokens for thinking text generation (default: 1000).",
)
parser.add_argument(
"--do-sample",
action="store_true",
default=False,
help="Enable sampling for text generation (default: greedy).",
)
parser.add_argument(
"--text-temperature",
type=float,
default=0.3,
help="Temperature for text generation sampling (default: 0.3).",
)
args = parser.parse_args()
return args
def main():
args = parse_args()
os.makedirs(args.output, exist_ok=True)
model_name = args.model
prompts: list[OmniPromptType] = []
try:
if getattr(args, "txt_prompts", None) and args.prompt_type == "text":
with open(args.txt_prompts, encoding="utf-8") as f:
lines = [ln.strip() for ln in f.readlines()]
prompts = [ln for ln in lines if ln != ""]
print(f"[Info] Loaded {len(prompts)} prompts from {args.txt_prompts}")
else:
prompts = args.prompts
except Exception as e:
print(f"[Error] Failed to load prompts: {e}")
raise
if not prompts:
prompts = ["A cute cat"]
print(f"[Info] No prompts provided, using default: {prompts}")
from PIL import Image
from vllm_omni.entrypoints.omni import Omni
omni_kwargs = vars(args).copy()
omni_kwargs.pop("model", None)
deploy_config = args.deploy_config
if args.think and deploy_config is None:
deploy_config = "vllm_omni/deploy/bagel_think.yaml"
print(f"[Info] Think mode enabled, using deploy config: {deploy_config}")
if deploy_config:
omni_kwargs["deploy_config"] = deploy_config
if args.quantization:
omni_kwargs["quantization_config"] = args.quantization
omni = Omni(model=model_name, **omni_kwargs)
formatted_prompts = []
for p in prompts:
if args.modality == "img2img":
if not args.image_path or not os.path.exists(args.image_path):
raise ValueError(f"img2img requires --image-path pointing to an existing file, got: {args.image_path}")
loaded_image = Image.open(args.image_path).convert("RGB")
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
final_prompt_text = f"{think_prefix}<|fim_middle|><|im_start|>{p}<|im_end|>"
prompt_dict = {
"prompt": final_prompt_text,
"multi_modal_data": {"img2img": loaded_image},
"modalities": ["img2img"],
}
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
formatted_prompts.append(prompt_dict)
elif args.modality == "img2text":
if args.image_path:
loaded_image = Image.open(args.image_path).convert("RGB")
think_prefix = f"<|im_start|>system\n{VLM_THINK_SYSTEM_PROMPT}<|im_end|>\n" if args.think else ""
final_prompt_text = (
f"{think_prefix}<|im_start|>user\n<|image_pad|>\n{p}<|im_end|>\n<|im_start|>assistant\n"
)
prompt_dict = {
"prompt": final_prompt_text,
"multi_modal_data": {"image": loaded_image},
"modalities": ["text"],
}
formatted_prompts.append(prompt_dict)
elif args.modality == "text2text":
think_prefix = f"<|im_start|>{VLM_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|><|im_start|>"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["text"]}
formatted_prompts.append(prompt_dict)
else:
think_prefix = f"<|im_start|>{GEN_THINK_SYSTEM_PROMPT}<|im_end|>" if args.think else ""
final_prompt_text = f"{think_prefix}<|im_start|>{p}<|im_end|>"
prompt_dict = {"prompt": final_prompt_text, "modalities": ["image"]}
if args.negative_prompt is not None:
prompt_dict["negative_prompt"] = args.negative_prompt
formatted_prompts.append(prompt_dict)
params_list = omni.default_sampling_params_list
# Bagel exposes 1 sampling param set for single-stage (DiT-only) and
# 2 for two-stage (Thinker + DiT). This heuristic may need updating
# if future pipelines break that 1:1 mapping.
is_single_stage = len(params_list) == 1
diffusion_params_idx = 0 if is_single_stage else (1 if len(params_list) > 1 else 0)
diffusion_params = params_list[diffusion_params_idx]
if args.modality in ("text2img", "img2img"):
diffusion_params.num_inference_steps = args.steps # type: ignore
diffusion_params.cfg_parallel_size = args.cfg_parallel_size # type: ignore
if args.seed is not None:
diffusion_params.seed = args.seed # type: ignore
extra = getattr(diffusion_params, "extra_args", {}) or {}
extra["cfg_text_scale"] = args.cfg_text_scale
extra["cfg_img_scale"] = args.cfg_img_scale
if args.cfg_interval is not None:
extra["cfg_interval"] = tuple(args.cfg_interval)
if args.cfg_renorm_type is not None:
extra["cfg_renorm_type"] = args.cfg_renorm_type
if args.cfg_renorm_min is not None:
extra["cfg_renorm_min"] = args.cfg_renorm_min
if args.negative_prompt is not None:
extra["negative_prompt"] = args.negative_prompt
needs_text_gen = is_single_stage and (args.think or args.modality in ("text2text", "img2text"))
if needs_text_gen:
if args.think:
extra["think"] = True
extra["max_think_tokens"] = args.max_think_tokens
extra["do_sample"] = args.do_sample
extra["text_temperature"] = args.text_temperature
diffusion_params.extra_args = extra # type: ignore
omni_outputs = list(omni.generate(prompts=formatted_prompts, sampling_params_list=params_list))
img_idx = 0
for req_output in omni_outputs:
# 2-stage think mode: text output from thinker stage
ro = getattr(req_output, "request_output", None)
if ro and getattr(ro, "outputs", None):
txt = "".join(getattr(o, "text", "") or "" for o in ro.outputs)
if txt:
if args.think:
print(f"[Think]\n{txt}")
else:
print(f"[Output] Text:\n{txt}")
# Single-stage DiT: text from custom_output
custom = getattr(req_output, "_custom_output", {}) or {}
if custom.get("think_text"):
print(f"[Think]\n{custom['think_text']}")
if custom.get("text_output"):
print(f"[Output] Text:\n{custom['text_output']}")
images = getattr(req_output, "images", None)
if not images:
continue
for j, img in enumerate(images):
save_path = os.path.join(args.output, f"output_{img_idx}_{j}.png")
img.save(save_path)
print(f"[Output] Saved image to {save_path}")
img_idx += 1
if __name__ == "__main__":
main()
@@ -0,0 +1,129 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""DreamZero AR client frame schedule aligned with upstream ``test_client_AR.py``."""
from __future__ import annotations
import logging
from typing import Any
import numpy as np
# Frame schedule constants (matching upstream debug_inference.py / test_client_AR.py).
ACTION_HORIZON = 24
DEFAULT_ACTION_DIM = 8
RELATIVE_OFFSETS = [-23, -16, -8, 0]
DEFAULT_NUM_AR_CHUNKS = 15
CAMERA_FILES = {
"observation/exterior_image_0_left": "exterior_image_1_left.mp4",
"observation/exterior_image_1_left": "exterior_image_2_left.mp4",
"observation/wrist_image_left": "wrist_image_left.mp4",
}
def build_frame_schedule(
total_frames: int,
num_chunks: int,
*,
logger: logging.Logger | None = None,
) -> list[list[int]]:
"""Build the frame index schedule for multi-frame chunks.
Args:
total_frames: Number of frames available in each camera video.
num_chunks: Number of 4-frame chunks to schedule after the initial frame.
Returns:
A list of frame-index lists. Each inner list has four indices. The
returned list may be shorter than ``num_chunks`` when the videos run
out of frames (upstream stops early instead of erroring).
"""
chunks: list[list[int]] = []
current_frame = 23
for _ in range(num_chunks):
indices = [max(current_frame + offset, 0) for offset in RELATIVE_OFFSETS]
if indices[-1] >= total_frames:
if logger is not None:
logger.info(
"Frame %s >= %s, stopping at %s chunks",
indices[-1],
total_frames,
len(chunks),
)
break
chunks.append(indices)
current_frame += ACTION_HORIZON
return chunks
def make_obs_from_video(
camera_frames: dict[str, np.ndarray],
frame_indices: list[int],
*,
prompt: str,
session_id: str,
) -> dict[str, Any]:
"""Build an observation dict from real video frames.
For one frame each image key is ``(H, W, 3)``. For four frames each key is
``(4, H, W, 3)``.
"""
obs: dict[str, Any] = {}
for camera_key, all_frames in camera_frames.items():
selected = all_frames[frame_indices]
obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected
obs["observation/joint_position"] = np.zeros(7, dtype=np.float32)
obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32)
obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32)
obs["prompt"] = prompt
obs["session_id"] = session_id
return obs
def build_ar_observations(
camera_frames: dict[str, np.ndarray],
*,
prompt: str,
session_id: str,
num_chunks: int = DEFAULT_NUM_AR_CHUNKS,
repeat_chunk_observations: bool = False,
logger: logging.Logger | None = None,
) -> list[dict[str, Any]]:
"""Build the AR observation sequence used by upstream ``test_client_AR.py``.
Step 0 sends frame ``[0]``. Each subsequent step sends one 4-frame chunk.
``num_chunks`` counts only the 4-frame chunks after the initial frame, not
the total number of inferences.
"""
if num_chunks < 0:
raise ValueError("num_chunks must be non-negative")
total_frames = min(frames.shape[0] for frames in camera_frames.values())
observations = [
make_obs_from_video(
camera_frames,
[0],
prompt=prompt,
session_id=session_id,
)
]
chunk_schedule = build_frame_schedule(total_frames, num_chunks, logger=logger)
if repeat_chunk_observations and chunk_schedule and len(chunk_schedule) < num_chunks:
while len(chunk_schedule) < num_chunks:
chunk_schedule.append(chunk_schedule[-1])
for frame_indices in chunk_schedule:
observations.append(
make_obs_from_video(
camera_frames,
frame_indices,
prompt=prompt,
session_id=session_id,
)
)
return observations
@@ -11,6 +11,11 @@ from pathlib import Path
import cv2
import numpy as np
import torch
from client_schedule import (
CAMERA_FILES,
DEFAULT_NUM_AR_CHUNKS,
build_ar_observations,
)
from PIL import Image
from vllm_omni import Omni
@@ -20,13 +25,6 @@ from vllm_omni.outputs import OmniRequestOutput
WORKER_EXTENSION = "vllm_omni.diffusion.models.dreamzero.video_export_worker.DreamZeroVideoExportWorkerExtension"
ASSET_REPO_ID = "YangshenDeng/vllm-omni-dreamzero-assets"
DEFAULT_SESSION_PREFIX = "dreamzero-export"
RELATIVE_OFFSETS = [-23, -16, -8, 0]
ACTION_HORIZON = 24
CAMERA_FILES = {
"observation/exterior_image_0_left": "exterior_image_1_left.mp4",
"observation/exterior_image_1_left": "exterior_image_2_left.mp4",
"observation/wrist_image_left": "wrist_image_left.mp4",
}
def _parse_args() -> argparse.Namespace:
@@ -49,6 +47,17 @@ def _parse_args() -> argparse.Namespace:
default="Move the pan forward and use the brush in the middle of the plates to brush the inside of the pan",
)
parser.add_argument("--session-id", default=None)
parser.add_argument(
"--num-chunks",
type=int,
default=DEFAULT_NUM_AR_CHUNKS,
help="Number of 4-frame chunks to send after the initial frame (default: 15).",
)
parser.add_argument(
"--repeat-chunk-observations",
action="store_true",
help="Repeat the last valid chunk observation when bundled assets run out of frames.",
)
parser.add_argument("--save-input-video", action="store_true")
parser.add_argument("--save-gif", action="store_true")
parser.add_argument("--save-actions", action="store_true")
@@ -84,57 +93,25 @@ def _load_camera_frames(video_dir: Path) -> dict[str, np.ndarray]:
return camera_frames
def _build_frame_schedule(total_frames: int, num_chunks: int) -> list[list[int]]:
chunks: list[list[int]] = []
current_frame = 23
for _ in range(num_chunks):
indices = [max(current_frame + offset, 0) for offset in RELATIVE_OFFSETS]
if indices[-1] >= total_frames:
break
chunks.append(indices)
current_frame += ACTION_HORIZON
return chunks
def _make_obs_from_video(
camera_frames: dict[str, np.ndarray],
frame_indices: list[int],
*,
def _build_observations(
video_dir: Path,
prompt: str,
session_id: str,
) -> dict:
obs: dict = {}
for camera_key, all_frames in camera_frames.items():
selected = all_frames[frame_indices]
obs[camera_key] = selected[0] if len(frame_indices) == 1 else selected
obs["observation/joint_position"] = np.zeros(7, dtype=np.float32)
obs["observation/cartesian_position"] = np.zeros(6, dtype=np.float32)
obs["observation/gripper_position"] = np.zeros(1, dtype=np.float32)
obs["prompt"] = prompt
obs["session_id"] = session_id
return obs
def _build_observations(video_dir: Path, prompt: str, session_id: str) -> tuple[dict[str, np.ndarray], list[dict]]:
*,
num_chunks: int,
repeat_chunk_observations: bool,
) -> tuple[dict[str, np.ndarray], list[dict]]:
camera_frames = _load_camera_frames(video_dir)
total_frames = min(frames.shape[0] for frames in camera_frames.values())
chunks = _build_frame_schedule(total_frames, 1)
observations = [
_make_obs_from_video(camera_frames, [0], prompt=prompt, session_id=session_id),
]
if chunks:
observations.append(
_make_obs_from_video(
camera_frames,
chunks[0],
prompt=prompt,
session_id=session_id,
)
)
observations = build_ar_observations(
camera_frames,
prompt=prompt,
session_id=session_id,
num_chunks=num_chunks,
repeat_chunk_observations=repeat_chunk_observations,
)
if len(observations) < 2:
raise RuntimeError("Need at least two DreamZero example observations to export a prediction video.")
return camera_frames, observations[:2]
raise RuntimeError("Need at least two DreamZero observations to export a prediction video.")
return camera_frames, observations
def _extract_latents(output: OmniRequestOutput) -> torch.Tensor:
@@ -229,6 +206,26 @@ def _run_generation(
return omni, outputs
def _decode_accumulated_with_worker(omni: Omni, session_id: str) -> np.ndarray:
"""Decode server-side accumulated latents when the pipeline supports it."""
stage_client = omni.engine.stage_clients[0]
engine = getattr(stage_client, "_engine", None)
if engine is None:
raise RuntimeError("DreamZero export requires inline diffusion stage access.")
decoded = engine.executor.collective_rpc(
"decode_accumulated_video_latents_to_uint8",
args=(session_id,),
unique_reply_rank=0,
exec_all_ranks=True,
)
if isinstance(decoded, torch.Tensor):
decoded = decoded.numpy()
if not isinstance(decoded, np.ndarray):
raise TypeError(f"Unexpected decoded output type: {type(decoded)!r}")
return decoded
def _decode_with_worker(omni: Omni, full_latents: torch.Tensor) -> np.ndarray:
stage_client = omni.engine.stage_clients[0]
engine = getattr(stage_client, "_engine", None)
@@ -248,6 +245,15 @@ def _decode_with_worker(omni: Omni, full_latents: torch.Tensor) -> np.ndarray:
return decoded
def _decode_video(omni: Omni, session_id: str, outputs: list[OmniRequestOutput]) -> np.ndarray:
"""Decode full AR rollout by concatenating per-step latents from all outputs."""
latent_steps = [_extract_latents(output) for output in outputs]
latent_t = [step.shape[2] for step in latent_steps]
print(f"Decoding {len(latent_steps)} latent steps, latent_t={latent_t}, total_t={sum(latent_t)}")
full_latents = torch.cat(latent_steps, dim=2)
return _decode_with_worker(omni, full_latents)
def main() -> None:
args = _parse_args()
session_id = args.session_id or f"{DEFAULT_SESSION_PREFIX}-{uuid.uuid4()}"
@@ -256,6 +262,12 @@ def main() -> None:
video_dir=args.video_dir,
prompt=args.prompt,
session_id=session_id,
num_chunks=args.num_chunks,
repeat_chunk_observations=args.repeat_chunk_observations,
)
print(
f"Running {len(observations)} inferences "
f"(1 initial + {len(observations) - 1} chunks; requested {args.num_chunks} post-initial chunks)"
)
args.output_dir.mkdir(parents=True, exist_ok=True)
@@ -273,17 +285,19 @@ def main() -> None:
deploy_config_path=args.deploy_config,
observations=observations,
)
latent_steps = [_extract_latents(output) for output in outputs]
full_latents = torch.cat(latent_steps, dim=2)
frames = _decode_with_worker(omni, full_latents)
frames = _decode_video(omni, session_id, outputs)
finally:
if omni is not None:
omni.close()
expected_frames = 8 * len(observations) + 1
mp4_path = args.output_dir / f"{args.output_stem}.mp4"
_write_mp4(mp4_path, frames, fps=args.fps)
print(f"SAVED_MP4={mp4_path}")
print(
f"SAVED_MP4={mp4_path} frames={len(frames)} "
f"expected≈{expected_frames} duration≈{len(frames) / max(args.fps, 1):.1f}s"
)
if args.save_gif:
gif_path = args.output_dir / f"{args.output_stem}.gif"
@@ -294,8 +308,10 @@ def main() -> None:
npz_path = args.output_dir / f"{args.output_stem}_actions.npz"
np.savez(
npz_path,
step0=np.asarray(outputs[0].multimodal_output.get("actions")),
step1=np.asarray(outputs[1].multimodal_output.get("actions")),
**{
f"step{index}": np.asarray(output.multimodal_output.get("actions"))
for index, output in enumerate(outputs)
},
)
print(f"SAVED_ACTIONS={npz_path}")
@@ -97,9 +97,16 @@ import torch
from PIL import Image
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.model_extras import (
build_image_to_image_prompt,
get_extra_body_params,
get_model_class_name,
should_init_extra_args_for_non_diffusion_stages,
)
from vllm_omni.platforms import current_omni_platform
@@ -187,6 +194,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--guidance-scale-2", type=float, default=None, help="image guidance scale for image-to-image generation."
)
parser.add_argument(
"--extra-args",
type=parse_profiler_config,
default=None,
help="JSON object copied to OmniDiffusionSamplingParams.extra_args, e.g. '{\"cfg_text_scale\": 4.0}'.",
)
parser.add_argument(
"--output",
type=str,
@@ -468,6 +481,8 @@ def main():
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
profiler_config=args.profiler_config,
)
model_class_name = get_model_class_name(omni)
declared_extra_body_params = get_extra_body_params(model_class_name)
print("Pipeline loaded")
profiler_enabled = args.profiler_config is not None
@@ -478,6 +493,8 @@ def main():
print(f" Model: {args.model}")
print(f" Inference steps: {args.num_inference_steps}")
print(f" Cache backend: {args.cache_backend if args.cache_backend else 'None (no acceleration)'}")
if args.height is not None or args.width is not None:
print(f" Output size: {args.width or 'auto'}x{args.height or 'auto'}")
if isinstance(input_image, list):
print(f" Number of input images: {len(input_image)}")
for idx, img in enumerate(input_image):
@@ -487,7 +504,6 @@ def main():
print(
f" Parallel configuration: ulysses_degree={args.ulysses_degree}, ring_degree={args.ring_degree}, cfg_parallel_size={args.cfg_parallel_size}, tensor_parallel_size={args.tensor_parallel_size}, enable_expert_parallel: {args.enable_expert_parallel}"
)
print(f" Output image size: {args.width or 'default'}x{args.height or 'default'}")
print(f"{'=' * 60}\n")
generation_start = time.perf_counter()
@@ -496,10 +512,20 @@ def main():
print("[Profiler] Starting profiling...")
omni.start_profile()
# Generate edited image
sampling_params = OmniDiffusionSamplingParams(
width=args.width,
prompt_dict = build_image_to_image_prompt(
model_class_name=model_class_name,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
input_image=input_image,
height=args.height,
width=args.width,
)
extra_args_from_cli = dict(args.extra_args or {})
if args.negative_prompt is not None:
extra_args_from_cli.setdefault("negative_prompt", args.negative_prompt)
diffusion_params = OmniDiffusionSamplingParams(
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
@@ -507,17 +533,44 @@ def main():
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_outputs_per_prompt,
layers=args.layers,
resolution=args.resolution,
height=args.height,
width=args.width,
)
if args.resolution:
sampling_params.resolution = args.resolution
outputs = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"image": input_image},
},
sampling_params,
if declared_extra_body_params:
apply_declared_extra_args(
diffusion_params,
declared_extra_body_params,
extra_args_from_cli,
)
else:
diffusion_params.extra_args.update({k: v for k, v in extra_args_from_cli.items() if v is not None})
# Build per-stage sampling params for multi-stage models
init_non_diffusion = should_init_extra_args_for_non_diffusion_stages(
model_class_name,
)
defaults = list(omni.default_sampling_params_list or [])
sampling_params_list = [clone_sampling_params(p) for p in defaults]
if not sampling_params_list:
sampling_params_list = [diffusion_params]
diffusion_replaced = False
for idx, params in enumerate(sampling_params_list):
if isinstance(params, OmniDiffusionSamplingParams):
merged_extra = dict(getattr(params, "extra_args", {}) or {})
merged_extra.update(diffusion_params.extra_args)
diffusion_params.extra_args = merged_extra
sampling_params_list[idx] = diffusion_params
diffusion_replaced = True
elif init_non_diffusion and hasattr(params, "extra_args"):
if params.extra_args is None:
params.extra_args = {}
if not diffusion_replaced and len(sampling_params_list) == 1:
sampling_params_list = [diffusion_params]
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
@@ -544,17 +597,16 @@ def main():
if not outputs:
raise ValueError("No output generated from omni.generate()")
# Extract images from OmniRequestOutput
# omni.generate() returns list[OmniRequestOutput], extract images from request_output.images
first_output = outputs[0]
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
images = None
for output in outputs:
images = getattr(output, "images", None)
if images:
break
req_out = getattr(output, "request_output", None)
images = getattr(req_out, "images", None) if req_out is not None else None
if images:
break
req_out = first_output.request_output
if not isinstance(req_out, OmniRequestOutput) or not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images' key")
images = req_out.images
if not images:
raise ValueError("No images found in request_output")
@@ -257,8 +257,11 @@ async def run_single_request(
new_chunks = audio_data[audio_list_consumed:]
audio_list_consumed = len(audio_data)
elif isinstance(audio_data, torch.Tensor):
new_chunks = [audio_data]
audio_last_tensor = audio_data
if audio_data is not audio_last_tensor:
new_chunks = [audio_data]
audio_last_tensor = audio_data
else:
new_chunks = []
else:
new_chunks = []
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# =============================================================================
# Qwen3-Omni end2end functional test script
# Uses default built-in assets, iterates over query-type x output-modalities combinations
# =============================================================================
set -euo pipefail
export VLLM_OMNI_USE_V2_RUNNER=1
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
END2END="$SCRIPT_DIR/end2end.py"
LOG_DIR="$SCRIPT_DIR/test_logs"
SUMMARY_FILE="$LOG_DIR/summary.log"
rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"
> "$SUMMARY_FILE"
PASS=0
FAIL=0
TOTAL=0
run_test() {
local name="$1"
shift
local log_file="$LOG_DIR/${name}.log"
local output_dir="$LOG_DIR/${name}_output"
TOTAL=$((TOTAL + 1))
echo "========================================"
echo "[${TOTAL}] Running: ${name}"
echo " Command: python $END2END $*"
echo "========================================"
local start_time
start_time=$(date +%s)
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => PASS (${elapsed}s)"
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
PASS=$((PASS + 1))
else
local exit_code=$?
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
# Append last 30 lines of log to summary
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
echo " --- end ---" >> "$SUMMARY_FILE"
FAIL=$((FAIL + 1))
fi
echo ""
}
# =============================================================================
# Test cases
# =============================================================================
# --- 1. Text-only input ---
run_test "text_to_text" \
--model "$MODEL_PATH" --query-type text --modalities text
run_test "text_to_text_audio" \
--model "$MODEL_PATH" --query-type text --modalities text,audio
run_test "text_to_audio" \
--model "$MODEL_PATH" --query-type text --modalities audio
# --- 2. Image input ---
run_test "image_to_text" \
--model "$MODEL_PATH" --query-type use_image --modalities text
run_test "image_to_text_audio" \
--model "$MODEL_PATH" --query-type use_image --modalities text,audio
# --- 3. Audio input ---
run_test "audio_to_text" \
--model "$MODEL_PATH" --query-type use_audio --modalities text
run_test "audio_to_text_audio" \
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio
# --- 4. Video input ---
run_test "video_to_text" \
--model "$MODEL_PATH" --query-type use_video --modalities text
run_test "video_to_text_audio" \
--model "$MODEL_PATH" --query-type use_video --modalities text,audio
# --- 5. Multi-audio input ---
run_test "multi_audios_to_text" \
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text
run_test "multi_audios_to_text_audio" \
--model "$MODEL_PATH" --query-type use_multi_audios --modalities text,audio
# --- 6. Mixed modality input (audio+image+video) ---
run_test "mixed_to_text" \
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text
run_test "mixed_to_text_audio" \
--model "$MODEL_PATH" --query-type use_mixed_modalities --modalities text,audio
# --- 7. Audio from video ---
run_test "audio_in_video_to_text" \
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text
run_test "audio_in_video_to_text_audio" \
--model "$MODEL_PATH" --query-type use_audio_in_video --modalities text,audio
# --- 8. py-generator mode ---
run_test "text_to_text_generator" \
--model "$MODEL_PATH" --query-type text --modalities text --py-generator
run_test "audio_to_text_audio_generator" \
--model "$MODEL_PATH" --query-type use_audio --modalities text,audio --py-generator
# --- 9. Multi-prompt batch inference ---
run_test "text_to_text_batch3" \
--model "$MODEL_PATH" --query-type text --modalities text --num-prompts 3
# =============================================================================
# Summary
# =============================================================================
echo "========================================" | tee -a "$SUMMARY_FILE"
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
echo "========================================" | tee -a "$SUMMARY_FILE"
echo ""
echo "Detailed logs: $LOG_DIR"
echo "Summary report: $SUMMARY_FILE"
exit $FAIL
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# =============================================================================
# Qwen3-Omni end2end_async_chunk functional test script
# Uses default built-in assets, iterates over AsyncOmni query-type x output-modalities combinations
# =============================================================================
set -euo pipefail
export VLLM_OMNI_USE_V2_RUNNER=1
MODEL_PATH="/models/Qwen3-Omni-30B-A3B-Instruct"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
END2END="$SCRIPT_DIR/end2end_async_chunk.py"
STAGE_CONFIGS="$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_omni_moe_async_chunk.yaml"
LOG_DIR="$SCRIPT_DIR/test_logs_async_chunk"
SUMMARY_FILE="$LOG_DIR/summary.log"
rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"
> "$SUMMARY_FILE"
PASS=0
FAIL=0
TOTAL=0
run_test() {
local name="$1"
shift
local log_file="$LOG_DIR/${name}.log"
local output_dir="$LOG_DIR/${name}_output"
TOTAL=$((TOTAL + 1))
echo "========================================"
echo "[${TOTAL}] Running: ${name}"
echo " Command: python $END2END $*"
echo "========================================"
local start_time
start_time=$(date +%s)
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => PASS (${elapsed}s)"
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
PASS=$((PASS + 1))
else
local exit_code=$?
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
# Append last 30 lines of log to summary
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
echo " --- end ---" >> "$SUMMARY_FILE"
FAIL=$((FAIL + 1))
fi
echo ""
}
# =============================================================================
# Test cases
# =============================================================================
# --- 1. Text-only input ---
run_test "text_to_text" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text
run_test "text_to_text_audio" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text,audio
run_test "text_to_audio" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities audio
# --- 2. Image input ---
run_test "image_to_text" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_image --modalities text
run_test "image_to_text_audio" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_image --modalities text,audio
# --- 3. Audio input ---
run_test "audio_to_text" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_audio --modalities text
run_test "audio_to_text_audio" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_audio --modalities text,audio
# --- 4. Video input ---
run_test "video_to_text" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_video --modalities text
run_test "video_to_text_audio" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_video --modalities text,audio
# --- 5. Multi-prompt batch inference ---
run_test "text_to_text_batch3" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text --num-prompts 3
# --- 6. Concurrent requests (max-in-flight) ---
run_test "text_to_text_audio_concurrent2" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text,audio --num-prompts 2 --max-in-flight 2
# --- 7. stream-audio-to-disk mode ---
run_test "audio_to_text_audio_stream" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type use_audio --modalities text,audio --stream-audio-to-disk
# --- 8. request-timeout test (long enough timeout, should PASS) ---
run_test "text_to_text_with_timeout" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text --request-timeout-s 600
# --- 9. batch-timeout test (long enough timeout, should PASS) ---
run_test "text_to_text_audio_batch_timeout" \
--model "$MODEL_PATH" --stage-configs-path "$STAGE_CONFIGS" \
--query-type text --modalities text,audio --batch-timeout-s 600
# =============================================================================
# Summary
# =============================================================================
echo "========================================" | tee -a "$SUMMARY_FILE"
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
echo "========================================" | tee -a "$SUMMARY_FILE"
echo ""
echo "Detailed logs: $LOG_DIR"
echo "Summary report: $SUMMARY_FILE"
exit $FAIL
@@ -145,7 +145,7 @@ python end2end.py \
## Reducing GPU Memory Usage
For hardware with limited VRAM, enable **module-wise CPU offload** with
`--enable-cpu-offload`. The pipeline implements `SupportsModuleOffload`, so the
`--enable-cpu-offload`. The pipeline implements `SupportsComponentDiscovery`, so the
vision encoder (`vision_model`) and the Qwen3 LLM (`language_model`) are
swapped between CPU and GPU on demand:
@@ -10,10 +10,18 @@ from typing import Any
import torch
from vllm_omni.diffusion.data import logger
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
from vllm_omni.model_extras import (
build_text_to_image_prompt,
get_extra_body_params,
get_model_class_name,
should_init_extra_args_for_non_diffusion_stages,
)
from vllm_omni.platforms import current_omni_platform
@@ -259,6 +267,19 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="[NextStep-1.1 only] Apply layer normalization to sampled tokens.",
)
parser.add_argument(
"--extra-body",
type=parse_profiler_config,
default=None,
help=(
"Model-specific generation params as a JSON object, e.g. "
'\'{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_interval": [0.4, 1.0]}\'. '
"Each key is filtered against the model's declared extra_body_params "
"(see vllm_omni/model_extras), so unknown keys for the chosen model are "
"silently dropped. Values here take precedence over the equivalent "
"model-specific flags above."
),
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
@@ -405,6 +426,8 @@ def main():
# NextStep-1.1 requires explicit pipeline class
omni_kwargs["model_class_name"] = "NextStep11Pipeline"
omni = Omni(**omni_kwargs)
model_class_name = get_model_class_name(omni)
declared_extra_body_params = get_extra_body_params(model_class_name)
if profiler_enabled:
print("[Profiler] Starting profiling...")
@@ -445,34 +468,79 @@ def main():
)
generation_start = time.perf_counter()
extra_args = {
prompt_dict = build_text_to_image_prompt(
model_class_name=model_class_name,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
height=args.height,
width=args.width,
)
diffusion_params = OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
seed=args.seed,
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_images_per_prompt,
)
# Base layer: backward-compatible model-specific flags. New model params should
# instead be declared in vllm_omni/model_extras and passed via --extra-body, so
# this dict does not need to grow per-model.
user_extra = {
"cfg_scale": args.cfg_scale,
"cfg_text_scale": args.cfg_scale,
"negative_prompt": args.negative_prompt,
"timestep_shift": args.timesteps_shift,
"timesteps_shift": args.timesteps_shift,
"cfg_schedule": args.cfg_schedule,
"use_norm": args.use_norm,
"use_system_prompt": args.use_system_prompt,
"system_prompt": args.system_prompt,
}
if lora_request:
extra_args["lora_request"] = lora_request
extra_args["lora_scale"] = args.lora_scale
# Override layer: generic JSON passthrough wins over the flags above. Keys are
# still filtered against the model's declared extra_body_params downstream.
if args.extra_body:
user_extra.update(args.extra_body)
if declared_extra_body_params:
apply_declared_extra_args(diffusion_params, declared_extra_body_params, user_extra)
else:
diffusion_params.extra_args.update({k: v for k, v in user_extra.items() if v is not None})
outputs = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
},
OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
generator=generator,
true_cfg_scale=args.cfg_scale,
guidance_scale=args.guidance_scale,
guidance_scale_2=args.guidance_scale_2,
num_inference_steps=args.num_inference_steps,
num_outputs_per_prompt=args.num_images_per_prompt,
extra_args=extra_args,
),
if lora_request:
diffusion_params.extra_args["lora_request"] = lora_request
diffusion_params.extra_args["lora_scale"] = args.lora_scale
# Build per-stage sampling params for multi-stage models (e.g. BAGEL),
# or wrap single diffusion params for single-stage models.
init_non_diffusion = should_init_extra_args_for_non_diffusion_stages(
model_class_name,
)
defaults = list(omni.default_sampling_params_list or [])
sampling_params_list = [clone_sampling_params(p) for p in defaults]
if not sampling_params_list:
sampling_params_list = [diffusion_params]
diffusion_replaced = False
for idx, params in enumerate(sampling_params_list):
if isinstance(params, OmniDiffusionSamplingParams):
sampling_params_list[idx] = diffusion_params
diffusion_replaced = True
elif init_non_diffusion and hasattr(params, "extra_args"):
if params.extra_args is None:
params.extra_args = {}
if args.seed is not None and hasattr(params, "seed"):
params.seed = args.seed
if not diffusion_replaced and len(sampling_params_list) == 1:
sampling_params_list = [diffusion_params]
outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
@@ -502,15 +570,16 @@ def main():
raise ValueError("No output generated from omni.generate()")
logger.info(f"Outputs: {outputs}")
first_output = outputs[0]
if not hasattr(first_output, "request_output") or not first_output.request_output:
raise ValueError("No request_output found in OmniRequestOutput")
images = None
for output in outputs:
images = getattr(output, "images", None)
if images:
break
req_out = getattr(output, "request_output", None)
images = getattr(req_out, "images", None) if req_out is not None else None
if images:
break
req_out = first_output.request_output
if not hasattr(req_out, "images"):
raise ValueError("Invalid request_output structure or missing 'images'.")
images = req_out.images
if not images:
raise ValueError("No images found in request_output")
@@ -135,17 +135,18 @@ def _estimate_prompt_len(
return 2048
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
def get_custom_voice_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
"""Build CustomVoice sample inputs.
Args:
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
model_name: Override the default HF model path (e.g. with a local path).
Returns:
QueryResult with Omni inputs and the CustomVoice model path.
"""
task_type = "CustomVoice"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
if use_batch_sample:
texts = [
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
@@ -196,17 +197,18 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
)
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
def get_voice_design_query(use_batch_sample: bool = False, model_name: str | None = None) -> QueryResult:
"""Build VoiceDesign sample inputs.
Args:
use_batch_sample: When True, return a batch of prompts; otherwise a single prompt.
model_name: Override the default HF model path (e.g. with a local path).
Returns:
QueryResult with Omni inputs and the VoiceDesign model path.
"""
task_type = "VoiceDesign"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
if use_batch_sample:
texts = [
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
@@ -255,18 +257,19 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
)
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl", model_name: str | None = None) -> QueryResult:
"""Build Base (voice clone) sample inputs.
Args:
use_batch_sample: When True, return a batch of prompts (Case 2).
mode_tag: "icl" or "xvec_only" to control x_vector_only_mode behavior.
model_name: Override the default HF model path (e.g. with a local path).
Returns:
QueryResult with Omni inputs and the Base model path.
"""
task_type = "Base"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
model_name = model_name or "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
ref_audio_single = ref_audio_path_1
ref_text_single = (
@@ -335,14 +338,17 @@ def _build_inputs(args) -> tuple[str, list]:
)
query_func = query_map[args.query_type]
model_override = args.model if args.model else None
if args.query_type in {"CustomVoice", "VoiceDesign"}:
query_result = query_func(use_batch_sample=args.use_batch_sample)
query_result = query_func(use_batch_sample=args.use_batch_sample, model_name=model_override)
elif args.query_type == "Base":
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
query_result = query_func(
use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag, model_name=model_override
)
else:
query_result = query_func()
model_name = query_result.model_name
model_name = args.model if args.model else query_result.model_name
if args.txt_prompts:
with open(args.txt_prompts) as f:
@@ -359,7 +365,10 @@ def _build_inputs(args) -> tuple[str, list]:
for t in lines
]
else:
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
if isinstance(query_result.inputs, list):
inputs = query_result.inputs * args.num_prompts
else:
inputs = [query_result.inputs] * args.num_prompts
return model_name, inputs
@@ -434,6 +443,12 @@ async def main_streaming(args):
def parse_args():
parser = TrackingArgumentParser(description="Demo on using vLLM for offline inference with audio language models")
parser.add_argument(
"--model",
type=str,
default=None,
help="Override the default model path for the selected query type.",
)
parser.add_argument(
"--query-type",
"-q",
@@ -0,0 +1,640 @@
"""Offline benchmark script for Qwen3 TTS via vLLM Omni.
Supports warmup + test rounds with a single Omni instance to avoid
repeated model loading. Prints per-round wall time summary at the end.
"""
import asyncio
import logging
import os
import time
from typing import Any, NamedTuple
import soundfile as sf
import torch
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import AsyncOmni, Omni
logger = logging.getLogger(__name__)
class QueryResult(NamedTuple):
"""Container for a prepared Omni request."""
inputs: dict
model_name: str
_model_cache: dict[str, Any] = {}
def _estimate_prompt_len(
additional_information: dict[str, Any],
model_name: str,
) -> int:
"""Estimate prompt_token_ids placeholder length for the Talker stage."""
try:
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
if model_name not in _model_cache:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(
model_name, trust_remote_code=True, padding_side="left", fix_mistral_regex=True
)
cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
speech_tok = None
try:
from transformers.utils import cached_file
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_tokenizer import Qwen3TTSTokenizer
st_cfg_path = cached_file(model_name, "speech_tokenizer/config.json")
if st_cfg_path:
speech_tok = Qwen3TTSTokenizer.from_pretrained(
# os.path.dirname(st_cfg_path), torch_dtype=torch.bfloat16
os.path.dirname(st_cfg_path),
torch_dtype=torch.bfloat16,
fix_mistral_regex=True,
)
logger.info("Loaded speech tokenizer for exact ref_code_len estimation")
except Exception as e:
logger.debug("Could not load speech tokenizer: %s", e)
_model_cache[model_name] = (tok, getattr(cfg, "talker_config", None), speech_tok)
tok, tcfg, speech_tok = _model_cache[model_name]
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
def _estimate_ref_code_len(ref_audio: object) -> int | None:
if not isinstance(ref_audio, (str, list)):
return None
audio_path = ref_audio[0] if isinstance(ref_audio, list) else ref_audio
if not isinstance(audio_path, str) or not audio_path.strip():
return None
try:
from vllm.multimodal.media import MediaConnector
connector = MediaConnector(allowed_local_media_path="/")
audio, sr = connector.fetch_audio(audio_path)
import numpy as np
wav_np = np.asarray(audio, dtype=np.float32)
if speech_tok is not None:
enc = speech_tok.encode(wav_np, sr=int(sr), return_dict=True)
ref_code = getattr(enc, "audio_codes", None)
if isinstance(ref_code, list):
ref_code = ref_code[0] if ref_code else None
if ref_code is not None and hasattr(ref_code, "shape"):
shape = ref_code.shape
return int(shape[0]) if len(shape) == 2 else int(shape[1]) if len(shape) == 3 else None
codec_hz = getattr(tcfg, "codec_frame_rate", None) or 12
return int(len(audio) / sr * codec_hz)
except Exception:
return None
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
additional_information=additional_information,
task_type=task_type,
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
codec_language_id=getattr(tcfg, "codec_language_id", None),
spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
estimate_ref_code_len=_estimate_ref_code_len,
)
except Exception as exc:
logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
return 2048
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
task_type = "CustomVoice"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
if use_batch_sample:
texts = [
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
"She said she would be here by noon.",
"I like you very much.",
"Really, you do?",
"Yes, absolutely.",
]
instructs = ["", "Very happy.", "Very happy.", "Very happy.", "Very happy."]
languages = ["Chinese", "English", "English", "English", "English"]
speakers = ["Vivian", "Ryan", "Ryan", "Ryan", "Ryan"]
inputs = []
for text, instruct, language, speaker in zip(texts, instructs, languages, speakers):
additional_information = {
"task_type": [task_type],
"text": [text],
"instruct": [instruct],
"language": [language],
"speaker": [speaker],
"max_new_tokens": [2048],
}
inputs.append(
{
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
text = "其实我真的有发现,我是一个特别善于观察别人情绪的人。"
language = "Chinese"
speaker = "Vivian"
instruct = "用特别愤怒的语气说"
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"speaker": [speaker],
"instruct": [instruct],
"max_new_tokens": [2048],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(inputs=inputs, model_name=model_name)
def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
task_type = "VoiceDesign"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
if use_batch_sample:
texts = [
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
"It's in the top drawer... wait, it's empty? No way, that's impossible! I'm sure I put it there!",
]
instructs = [
"体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。",
"Speak in an incredulous tone, but with a hint of panic beginning to creep into your voice.",
]
languages = ["Chinese", "English"]
inputs = []
for text, instruct, language in zip(texts, instructs, languages):
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
}
inputs.append(
{
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
text = "哥哥,你回来啦,人家等了你好久好久了,要抱抱!"
instruct = "体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。"
language = "Chinese"
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(inputs=inputs, model_name=model_name)
def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> QueryResult:
task_type = "Base"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
ref_audio_single = ref_audio_path_1
ref_text_single = (
"Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."
)
syn_text_single = "Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye."
syn_lang_single = "Auto"
x_vector_only_mode = mode_tag == "xvec_only"
if use_batch_sample:
syn_text_batch = [
"Good one. Okay, fine, I'm just gonna leave this sock monkey here. Goodbye.",
"其实我真的有发现,我是一个特别善于观察别人情绪的人。",
]
syn_lang_batch = ["Chinese", "English"]
inputs = []
for text, language in zip(syn_text_batch, syn_lang_batch):
additional_information = {
"task_type": [task_type],
"ref_audio": [ref_audio_single],
"ref_text": [ref_text_single],
"text": [text],
"language": [language],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
}
inputs.append(
{
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
additional_information = {
"task_type": [task_type],
"ref_audio": [ref_audio_single],
"ref_text": [ref_text_single],
"text": [syn_text_single],
"language": [syn_lang_single],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(inputs=inputs, model_name=model_name)
query_map = {
"CustomVoice": get_custom_voice_query,
"VoiceDesign": get_voice_design_query,
"Base": get_base_query,
}
def _build_inputs(args) -> tuple[str, list]:
"""Resolve model name and inputs list from CLI args."""
if args.batch_size < 1 or (args.batch_size & (args.batch_size - 1)) != 0:
raise ValueError(
f"--batch-size must be a power of two (got {args.batch_size}); "
"non-power-of-two values do not align with CUDA graph capture sizes "
"of Code2Wav."
)
query_func = query_map[args.query_type]
if args.query_type in {"CustomVoice", "VoiceDesign"}:
query_result = query_func(use_batch_sample=args.use_batch_sample)
elif args.query_type == "Base":
query_result = query_func(use_batch_sample=args.use_batch_sample, mode_tag=args.mode_tag)
else:
query_result = query_func()
model_name = query_result.model_name
if args.txt_prompts:
with open(args.txt_prompts) as f:
lines = [line.strip() for line in f if line.strip()]
if not lines:
raise ValueError(f"No valid prompts found in {args.txt_prompts}")
template = query_result.inputs if not isinstance(query_result.inputs, list) else query_result.inputs[0]
template_info = template["additional_information"]
inputs = [
{
"prompt_token_ids": [0] * _estimate_prompt_len({**template_info, "text": [t]}, model_name),
"additional_information": {**template_info, "text": [t]},
}
for t in lines
]
else:
inputs = query_result.inputs if isinstance(query_result.inputs, list) else [query_result.inputs]
return model_name, inputs
def _save_wav(output_dir: str, request_id: str, mm: dict) -> None:
"""Concatenate audio chunks and write to a wav file."""
audio_data = mm["audio"]
sr_raw = mm["sr"]
sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw
sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val)
audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data
out_wav = os.path.join(output_dir, f"output_{request_id}.wav")
sf.write(out_wav, audio_tensor.float().cpu().numpy().flatten(), samplerate=sr, format="WAV")
logger.info("Saved audio to %s", out_wav)
class _SummaryCollector:
"""Monkey-patches omni._log_summary_and_cleanup to capture summary dicts.
NOTE: This relies on a private method ``_log_summary_and_cleanup`` which may
break if the upstream Omni implementation is refactored. If Omni exposes a
public hook for summary collection in the future, prefer that instead.
"""
def __init__(self, omni):
self.omni = omni
self.summaries: list[dict[str, Any]] = []
self.collecting = False
self._orig = omni._log_summary_and_cleanup
def install(self):
from pprint import pformat as _pformat
collector = self
def _patched(request_id: str) -> None:
req_state = collector.omni.request_states.get(request_id)
try:
if req_state is None or req_state.metrics is None:
return
summary = req_state.metrics.build_and_log_summary()
logger.info("[Summary] %s", _pformat(summary, sort_dicts=False))
if collector.collecting and summary:
collector.summaries.append(summary)
except Exception:
logger.exception("Failed to build/log summary for req=%s", request_id)
finally:
collector.omni.request_states.pop(request_id, None)
collector.omni._log_summary_and_cleanup = _patched
def _print_summary(round_times: list[float], summaries: list[dict[str, Any]] | None = None) -> None:
"""Print per-round wall time and optional detailed pipeline summary."""
print("\n" + "=" * 60)
print("BENCHMARK SUMMARY")
print("=" * 60)
for i, t in enumerate(round_times):
print(f" Test round {i}: {t:.3f}s")
print("-" * 60)
avg = sum(round_times) / len(round_times)
print(f" Rounds: {len(round_times)}")
print(f" Total: {sum(round_times):.3f}s")
print(f" Average: {avg:.3f}s")
print(f" Min: {min(round_times):.3f}s")
print(f" Max: {max(round_times):.3f}s")
print("=" * 60)
if not summaries:
print()
return
# --- Aggregate from captured summaries ---
overall_list = [s["overall_summary"] for s in summaries if "overall_summary" in s]
if not overall_list:
print()
return
n = len(overall_list)
# Average Wall time
avg_wall = sum(d["e2e_wall_time_ms"] for d in overall_list) / n
print(f"\n{'=' * 60}")
print("DETAILED PIPELINE SUMMARY (test rounds average)")
print("=" * 60)
print(f" Avg Wall Time: {avg_wall:.3f} ms")
# Per-stage timing
stage_keys = sorted(k for k in overall_list[0] if k.startswith("e2e_stage_") and k.endswith("_wall_time_ms"))
if stage_keys:
print("-" * 60)
print(" Per-stage timing (avg):")
for sk in stage_keys:
stage_avg = sum(d.get(sk, 0.0) for d in overall_list) / n
# extract stage index from key like e2e_stage_0_wall_time_ms
stage_idx = sk.replace("e2e_stage_", "").replace("_wall_time_ms", "")
print(f" Stage {stage_idx} wall time: {stage_avg:.3f} ms")
# Per-stage gen time from stage_table
all_stage_gen: dict[int, list[float]] = {}
for s in summaries:
for req_entry in s.get("stage_table", []):
for stage_info in req_entry.get("stages", []):
sid = stage_info.get("stage_id", -1)
gen_ms = stage_info.get("stage_gen_time_ms", 0.0)
all_stage_gen.setdefault(sid, []).append(gen_ms)
if all_stage_gen:
print(" Per-stage gen time (avg):")
for sid in sorted(all_stage_gen):
vals = all_stage_gen[sid]
print(f" Stage {sid} gen time: {sum(vals) / len(vals):.3f} ms")
# Per-prompt throughput
avg_tokens_per_s = sum(d.get("e2e_avg_tokens_per_s", 0.0) for d in overall_list) / n
avg_time_per_req = sum(d.get("e2e_avg_time_per_request_ms", 0.0) for d in overall_list) / n
total_tokens_list = [d.get("e2e_total_tokens", 0) for d in overall_list]
avg_total_tokens = sum(total_tokens_list) / n
print("-" * 60)
print(" Per-prompt throughput (avg):")
print(f" Tokens per request: {avg_total_tokens:.1f}")
print(f" Time per request: {avg_time_per_req:.3f} ms")
print(f" Tokens/s: {avg_tokens_per_s:.3f}")
print("=" * 60 + "\n")
def main(args):
"""Run sync benchmark: warmup rounds then test rounds."""
model_name, inputs = _build_inputs(args)
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
omni = Omni(
model=model_name,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
)
collector = _SummaryCollector(omni)
collector.install()
batch_size = args.batch_size
# --- Warmup ---
for r in range(args.warmup_rounds):
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
for batch_start in range(0, len(inputs), batch_size):
batch = inputs[batch_start : batch_start + batch_size]
for _ in omni.generate(batch):
pass
print(f"[Warmup {r}/{args.warmup_rounds}] done")
# --- Test ---
collector.collecting = True
round_times = []
for r in range(args.test_rounds):
print(f"[Test {r}/{args.test_rounds}] running...")
t_start = time.perf_counter()
for batch_start in range(0, len(inputs), batch_size):
batch = inputs[batch_start : batch_start + batch_size]
for stage_outputs in omni.generate(batch):
output = stage_outputs.request_output
_save_wav(
output_dir,
f"round{r}_{output.request_id}",
output.outputs[0].multimodal_output,
)
elapsed = time.perf_counter() - t_start
round_times.append(elapsed)
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
collector.collecting = False
_print_summary(round_times, collector.summaries if args.log_stats else None)
async def main_streaming(args):
"""Run async streaming benchmark: warmup rounds then test rounds."""
model_name, inputs = _build_inputs(args)
output_dir = args.output_dir
os.makedirs(output_dir, exist_ok=True)
omni = AsyncOmni(
model=model_name,
stage_configs_path=args.stage_configs_path,
log_stats=args.log_stats,
stage_init_timeout=args.stage_init_timeout,
enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler,
)
collector = _SummaryCollector(omni)
collector.install()
# --- Warmup ---
for r in range(args.warmup_rounds):
print(f"[Warmup {r}/{args.warmup_rounds}] running...")
for i, prompt in enumerate(inputs):
async for _ in omni.generate(prompt, request_id=f"warmup_{r}_{i}"):
pass
print(f"[Warmup {r}/{args.warmup_rounds}] done")
# --- Test ---
collector.collecting = True
round_times = []
for r in range(args.test_rounds):
print(f"[Test {r}/{args.test_rounds}] running...")
t_start = time.perf_counter()
for i, prompt in enumerate(inputs):
request_id = f"round{r}_{i}"
t_req_start = time.perf_counter()
t_prev = t_req_start
chunk_idx = 0
async for stage_output in omni.generate(prompt, request_id=request_id):
mm = stage_output.request_output.outputs[0].multimodal_output
if not stage_output.finished:
t_now = time.perf_counter()
audio = mm.get("audio")
n = len(audio) if isinstance(audio, list) else (0 if audio is None else 1)
if chunk_idx == 0:
ttfa_ms = (t_now - t_req_start) * 1000
logger.info("Request %s: chunk %d samples=%d TTFA=%.1fms", request_id, chunk_idx, n, ttfa_ms)
else:
dt_ms = (t_now - t_prev) * 1000
logger.info(
"Request %s: chunk %d samples=%d inter_chunk=%.1fms", request_id, chunk_idx, n, dt_ms
)
t_prev = t_now
chunk_idx += 1
else:
t_end = time.perf_counter()
total_ms = (t_end - t_req_start) * 1000
logger.info("Request %s: done total=%.1fms chunks=%d", request_id, total_ms, chunk_idx)
_save_wav(output_dir, request_id, mm)
elapsed = time.perf_counter() - t_start
round_times.append(elapsed)
print(f"[Test {r}/{args.test_rounds}] done in {elapsed:.3f}s")
collector.collecting = False
_print_summary(round_times, collector.summaries if args.log_stats else None)
def parse_args():
parser = FlexibleArgumentParser(description="Benchmark script for Qwen3 TTS via vLLM Omni")
parser.add_argument(
"--query-type",
"-q",
type=str,
default="CustomVoice",
choices=query_map.keys(),
help="Query type.",
)
parser.add_argument(
"--warmup-rounds",
type=int,
default=3,
help="Number of warmup rounds (results discarded, default: 3).",
)
parser.add_argument(
"--test-rounds",
type=int,
default=7,
help="Number of test rounds (results saved and timed, default: 7).",
)
parser.add_argument(
"--log-stats",
action="store_true",
default=False,
help="Enable writing detailed statistics (default: disabled).",
)
parser.add_argument(
"--stage-init-timeout",
type=int,
default=300,
help="Timeout for initializing a single stage in seconds (default: 300).",
)
parser.add_argument(
"--output-dir",
default="output_audio",
help="Output directory for generated wav files (default: output_audio).",
)
parser.add_argument(
"--txt-prompts",
type=str,
default=None,
help="Path to a .txt file with one prompt per line.",
)
parser.add_argument(
"--stage-configs-path",
type=str,
default=None,
help="Path to a stage configs file.",
)
parser.add_argument(
"--use-batch-sample",
action="store_true",
default=False,
help="Use batch input sample for CustomVoice/VoiceDesign/Base query.",
)
parser.add_argument(
"--mode-tag",
type=str,
default="icl",
choices=["icl", "xvec_only"],
help="Mode tag for Base query x_vector_only_mode (default: icl).",
)
parser.add_argument(
"--streaming",
action="store_true",
default=False,
help="Stream audio chunks via AsyncOmni.",
)
parser.add_argument(
"--batch-size",
type=int,
default=1,
help="Number of prompts per batch (default: 1).",
)
parser.add_argument(
"--enable-diffusion-pipeline-profiler",
action="store_true",
help="Enable diffusion pipeline profiler to display stage durations.",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
if args.streaming:
asyncio.run(main_streaming(args))
else:
main(args)
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# Qwen3-TTS end2end functional test script
# Iterates over query-type and mode combinations for qwen3_tts/end2end.py
#
# Usage:
# ./run_tests.sh --custom-voice-model /path/a --voice-design-model /path/b --base-model /path/c
# ./run_tests.sh --custom-voice-model /path/a # only run CustomVoice tests
# ./run_tests.sh --base-model /path/c # only run Base tests
# =============================================================================
set -euo pipefail
export VLLM_OMNI_USE_V2_RUNNER=1
# export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
# ---- Parse named arguments ----
CUSTOM_VOICE_MODEL=""
VOICE_DESIGN_MODEL=""
BASE_MODEL=""
usage() {
echo "Usage: $0 [--custom-voice-model PATH] [--voice-design-model PATH] [--base-model PATH]"
echo ""
echo "Pass one or more model paths. Only the query types with a model provided will be tested."
exit 1
}
if [[ $# -eq 0 ]]; then
usage
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--custom-voice-model) CUSTOM_VOICE_MODEL="$2"; shift 2 ;;
--voice-design-model) VOICE_DESIGN_MODEL="$2"; shift 2 ;;
--base-model) BASE_MODEL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
if [[ -z "$CUSTOM_VOICE_MODEL" && -z "$VOICE_DESIGN_MODEL" && -z "$BASE_MODEL" ]]; then
echo "Error: at least one model path must be provided."
usage
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
END2END="$SCRIPT_DIR/end2end.py"
LOG_DIR="$SCRIPT_DIR/test_logs"
SUMMARY_FILE="$LOG_DIR/summary.log"
rm -rf "$LOG_DIR"
mkdir -p "$LOG_DIR"
> "$SUMMARY_FILE"
PASS=0
FAIL=0
TOTAL=0
run_test() {
local name="$1"
shift
local log_file="$LOG_DIR/${name}.log"
local output_dir="$LOG_DIR/${name}_output"
TOTAL=$((TOTAL + 1))
echo "========================================"
echo "[${TOTAL}] Running: ${name}"
echo " Command: python $END2END $*"
echo "========================================"
local start_time
start_time=$(date +%s)
if python "$END2END" "$@" --output-dir "$output_dir" > "$log_file" 2>&1; then
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => PASS (${elapsed}s)"
echo "[PASS] ${name} (${elapsed}s)" >> "$SUMMARY_FILE"
PASS=$((PASS + 1))
else
local exit_code=$?
local end_time
end_time=$(date +%s)
local elapsed=$((end_time - start_time))
echo " => FAIL (exit code: ${exit_code}, ${elapsed}s)"
echo "[FAIL] ${name} (exit code: ${exit_code}, ${elapsed}s)" >> "$SUMMARY_FILE"
echo " --- last 30 lines of log ---" >> "$SUMMARY_FILE"
tail -n 30 "$log_file" >> "$SUMMARY_FILE"
echo " --- end ---" >> "$SUMMARY_FILE"
FAIL=$((FAIL + 1))
fi
echo ""
}
# =============================================================================
# Test cases — only run sections whose model path was provided
# =============================================================================
# --- 1. CustomVoice ---
if [[ -n "$CUSTOM_VOICE_MODEL" ]]; then
run_test "custom_voice" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice
run_test "custom_voice_batch_sample" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --use-batch-sample
run_test "custom_voice_py_generator" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --py-generator
run_test "custom_voice_streaming" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --streaming
run_test "custom_voice_batch4" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4
run_test "custom_voice_batch4_batch_sample" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --batch-size 4 --use-batch-sample --stage-configs-path "$SCRIPT_DIR/../../../vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml"
run_test "custom_voice_num_prompts3" \
--model "$CUSTOM_VOICE_MODEL" --query-type CustomVoice --num-prompts 3
fi
# --- 2. VoiceDesign ---
if [[ -n "$VOICE_DESIGN_MODEL" ]]; then
run_test "voice_design" \
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign
run_test "voice_design_batch_sample" \
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --use-batch-sample
run_test "voice_design_py_generator" \
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --py-generator
run_test "voice_design_streaming" \
--model "$VOICE_DESIGN_MODEL" --query-type VoiceDesign --streaming
fi
# --- 3. Base (icl mode) ---
if [[ -n "$BASE_MODEL" ]]; then
run_test "base_icl" \
--model "$BASE_MODEL" --query-type Base --mode-tag icl
run_test "base_icl_batch_sample" \
--model "$BASE_MODEL" --query-type Base --mode-tag icl --use-batch-sample
run_test "base_icl_py_generator" \
--model "$BASE_MODEL" --query-type Base --mode-tag icl --py-generator
run_test "base_icl_streaming" \
--model "$BASE_MODEL" --query-type Base --mode-tag icl --streaming
# --- 4. Base (xvec_only mode) ---
run_test "base_xvec_only" \
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only
run_test "base_xvec_only_batch_sample" \
--model "$BASE_MODEL" --query-type Base --mode-tag xvec_only --use-batch-sample
fi
# =============================================================================
# Summary
# =============================================================================
echo "========================================" | tee -a "$SUMMARY_FILE"
echo "Tests done: total ${TOTAL}, passed ${PASS}, failed ${FAIL}" | tee -a "$SUMMARY_FILE"
echo "========================================" | tee -a "$SUMMARY_FILE"
echo ""
echo "Detailed logs: $LOG_DIR"
echo "Summary report: $SUMMARY_FILE"
exit $FAIL
-404
View File
@@ -1,404 +0,0 @@
# BAGEL-7B-MoT
## Installation
Please refer to [README.md](../../../README.md)
## Architecture
BAGEL-7B-MoT is a Mixture-of-Transformers (MoT) model supporting both image generation and understanding. It offers two deployment topologies:
| Topology | Stages | Description |
| :------- | :----- | :---------- |
| **Two-stage** (default) | Stage 0 (Thinker, AR) + Stage 1 (DiT, Diffusion) | Thinker handles text/understanding via vLLM AR engine; DiT handles image generation. KV cache is transferred between stages. |
| **Single-stage** | Stage 0 (DiT, Diffusion) only | The DiT stage contains a full LLM, ViT, VAE, and tokenizer internally. All modalities are handled within a single diffusion process. |
Both topologies support all four modalities: `text2img`, `img2img`, `img2text`, `text2text`.
> **Note**: These examples work with the default configuration on an **NVIDIA A100 (80GB)**. We also tested on dual **NVIDIA RTX 5000 Ada (32GB each)**. For dual-GPU setups, modify the deploy YAML to distribute stages across devices.
## Launch the Server
### Two-Stage (Default)
The default pipeline is auto-detected from the model. No extra flags needed:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
```
Or use the convenience script:
```bash
cd examples/online_serving/bagel
bash run_server.sh
# Initialize each stage in a discrete isolated process terminal
bash run_server_stage_cli.sh --stage 0
bash run_server_stage_cli.sh --stage 1
```
To use a custom deploy YAML, pass it via `--deploy-config`:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
--deploy-config /path/to/deploy_config.yaml
```
See [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) for the default two-stage deploy configuration.
### Single-Stage
The DiT stage contains a full LLM, ViT, VAE, and tokenizer, so it can handle all modalities (text2img, img2img, img2text, text2text, think) without a separate Thinker stage:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
```
See [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) for configuration. The `pipeline: bagel_single_stage` field selects the single-stage topology from the pipeline registry.
### Tensor Parallelism (TP)
For larger models or multi-GPU environments, enable TP via CLI:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 --tensor-parallel-size 2
```
Or set `tensor_parallel_size` per stage in a custom deploy YAML.
### VAE Patch Parallelism
[VAE Patch Parallelism](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism/vae_patch_parallel.html) distributes Bagel VAE **decode/encode** across multiple GPUs by splitting latent tiles. It lowers **per-GPU peak memory during VAE decode**, which helps high-resolution `text2img` / `img2img` when VAE becomes a bottleneck.
**Scope for Bagel:**
| Topology | VAE patch parallel |
| :------- | :----------------- |
| **Single-stage** (DiT only) | Supported on stage 0 (`BagelPipeline` + `DistributedAutoEncoder`) |
| **Two-stage** | Supported on **stage 1 (DiT)** only; stage 0 (Thinker) uses encoder-only VAE and is unrelated |
**Requirements:**
- `vae_patch_parallel_size > 1` and a distributed VAE (`DistributedAutoEncoder` on the DiT pipeline).
- The DiT process group must have at least `vae_patch_parallel_size` ranks. In practice this means the diffusion stage `world_size` must be ≥ 2 (commonly `tensor_parallel_size=2` on that stage).
- `vae_use_tiling` must be enabled. If you set `vae_patch_parallel_size > 1` and omit tiling, the registry auto-enables `vae_use_tiling` at startup.
VAE patch parallel **reuses the DiT process group** (`dit_group`); it does not create a separate VAE-only worker pool. It is not a substitute for single-GPU VAE tiling (`vae_pp=1`).
**Online serving (single-stage, 2 GPUs):**
```bash
CUDA_VISIBLE_DEVICES=0,1 vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml \
--tensor-parallel-size 2 \
--vae-patch-parallel-size 2 \
--vae-use-tiling
```
**Online serving (two-stage, VAE PP on DiT stage 1):** use a custom deploy YAML, for example:
```yaml
stages:
- stage_id: 0
devices: "0"
# Thinker (AR) — no VAE patch parallel here
- stage_id: 1
devices: "0,1"
vae_use_tiling: true
parallel_config:
tensor_parallel_size: 2
vae_patch_parallel_size: 2
```
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091 \
--deploy-config /path/to/bagel_vae_pp.yaml
```
**Verify it is active** (check server logs at startup):
```text
INFO ... vae_patch_parallel_size=2 requires vae_use_tiling; automatically enabling it.
```
| CLI flag | Default | Description |
| :------- | :------ | :---------- |
| `--vae-patch-parallel-size` | `1` | Number of DiT ranks used for VAE tile parallelism. Set to `2` or higher to enable. Should be ≤ DiT process group size (typically match `--tensor-parallel-size` on the diffusion stage). |
| `--vae-use-tiling` | off | Enable VAE spatial tiling. Required for VAE patch parallel (auto-enabled when `vae_patch_parallel_size > 1`). |
#### Hybrid Sharded Data Parallel (HSDP)
For larger Bagel deployments on multiple GPUs, you can enable HSDP (Hybrid Sharded Data Parallel) by modifying the stage configuration (for example, [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml)). HSDP shards transformer weights across GPUs to reduce per-GPU memory usage.
1. **Enable HSDP**: Set `use_hsdp: true`.
2. **Set shard size**: Set `hsdp_shard_size` to the number of GPUs used for sharding (for example, `4`).
3. **Set replicate size**: Usually keep `hsdp_replicate_size: 1` unless you want replicated HSDP groups.
4. **Set devices**: Specify the comma-separated GPU IDs used by the diffusion stage (for example, `"0,1,2,3"`).
Example configuration for HSDP across 4 GPUs:
```yaml
- stage_id: 1
devices: "0,1,2,3"
parallel_config:
use_hsdp: true
hsdp_shard_size: 4
hsdp_replicate_size: 1
```
### Multi-Node Deployment
Deploy each stage on a **separate node** for better resource utilization. Replace `<ORCHESTRATOR_IP>` with the actual IP address of your orchestrator node.
**1. Launch Stage 0 (Thinker / Orchestrator)** on the orchestrator node:
```bash
# API server port for client requests: 8000
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni \
--port 8000 \
--stage-id 0 \
--omni-master-address <ORCHESTRATOR_IP> \
--omni-master-port 8091
```
**2. Launch Stage 1 (DiT)** on the remote node in headless mode:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni \
--stage-id 1 \
--headless \
--omni-master-address <ORCHESTRATOR_IP> \
--omni-master-port 8091
```
Or use the convenience script:
```bash
# Terminal 1: Stage 0
bash run_server_stage_cli.sh --stage 0
# Terminal 2: Stage 1
bash run_server_stage_cli.sh --stage 1
# With extra args
bash run_server_stage_cli.sh --stage 0 -- --tensor-parallel-size 2
bash run_server_stage_cli.sh --stage 1 -- --gpu-memory-utilization 0.9
```
**vllm serve arguments:**
| Argument | Description |
| :------- | :---------- |
| `--stage-id` | Which stage this process runs (0 = Thinker, 1 = DiT) |
| `--headless` | Run without the API server (worker-only mode) |
| `-oma` / `--omni-master-address` | Orchestrator master address |
| `-omp` / `--omni-master-port` | Orchestrator master port |
> [!IMPORTANT]
> **Startup Order**: Stage 0 (orchestrator) must be launched **before** Stage 1 (headless).
> Stage 0 will appear to hang on startup until Stage 1 (worker) connects — this is expected behavior.
### Inter-Stage Connectors
When deploying stages across nodes, configure the connector type in the deploy YAML:
- **SharedMemoryConnector** (default): Used for single-node deployments. No explicit configuration needed.
- **MooncakeTransferEngineConnector**: For multi-node setups with RDMA hardware. Defined in [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) under `connectors.rdma_connector`.
To use Mooncake, create a custom deploy YAML that binds `output_connectors` / `input_connectors` on each stage to the `rdma_connector` defined in the `connectors` section.
## Send Requests
```bash
cd examples/online_serving/bagel
```
### Text to Image (text2img)
**Python client:**
```bash
python openai_chat_client.py \
--prompt "A beautiful sunset over mountains" \
--modality text2img \
--output sunset.png \
--steps 50
```
**curl:**
```bash
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": [{"type": "text", "text": "<|im_start|>A beautiful sunset over mountains<|im_end|>"}]}],
"modalities": ["image"],
"height": 512,
"width": 512,
"num_inference_steps": 50,
"seed": 42
}'
```
### Image to Image (img2img)
**Python client:**
```bash
python openai_chat_client.py \
--prompt "Make the cat stand up" \
--modality img2img \
--image-url /path/to/input.jpg \
--output transformed.png
```
**curl:**
```bash
IMAGE_BASE64=$(base64 -w 0 cat.jpg)
cat <<EOF > payload.json
{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<|im_start|>Make the cat stand up<|im_end|>"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,${IMAGE_BASE64}"}}
]
}],
"modalities": ["image"],
"height": 512,
"width": 512,
"num_inference_steps": 50,
"seed": 42
}
EOF
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d @payload.json
```
### Image to Text (img2text)
**Python client:**
```bash
python openai_chat_client.py \
--prompt "Describe this image in detail" \
--modality img2text \
--image-url /path/to/image.jpg
```
**curl:**
```bash
IMAGE_BASE64=$(base64 -w 0 cat.jpg)
cat <<EOF > payload.json
{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "<|im_start|>user\n<|image_pad|>\nDescribe this image in detail<|im_end|>\n<|im_start|>assistant\n"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,${IMAGE_BASE64}"}}
]
}],
"modalities": ["text"]
}
EOF
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d @payload.json
```
### Text to Text (text2text)
**Python client:**
```bash
python openai_chat_client.py \
--prompt "What is the capital of France?" \
--modality text2text
```
**curl:**
```bash
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": [{"type": "text", "text": "<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\n"}]}],
"modalities": ["text"]
}'
```
### Python Client Arguments
| Argument | Default | Description |
| :------- | :------ | :---------- |
| `--prompt` / `-p` | `A cute cat` | Text prompt |
| `--output` / `-o` | `bagel_output.png` | Output file path |
| `--server` / `-s` | `http://localhost:8091` | Server URL |
| `--image-url` / `-i` | `None` | Input image URL or local path (img2img/img2text) |
| `--modality` / `-m` | `text2img` | `text2img`, `img2img`, `img2text`, `text2text` |
| `--height` | `512` | Image height in pixels |
| `--width` | `512` | Image width in pixels |
| `--steps` | `25` | Number of inference steps |
| `--seed` | `42` | Random seed |
| `--negative` | `None` | Negative prompt for CFG |
Example with custom parameters:
```bash
python openai_chat_client.py \
--prompt "A futuristic city" \
--modality text2img \
--height 768 \
--width 768 \
--steps 50 \
--seed 42 \
--negative "blurry, low quality"
```
## Configuration Reference
### Deploy YAML Files
| File | Description |
| :--- | :---------- |
| [`bagel.yaml`](../../../vllm_omni/deploy/bagel.yaml) | Two-stage default (Thinker + DiT on GPU 0) |
| [`bagel_single_stage.yaml`](../../../vllm_omni/deploy/bagel_single_stage.yaml) | Single-stage (DiT only) |
### Key Deploy YAML Fields
| Field | Scope | Description |
| :---- | :---- | :---------- |
| `pipeline` | top-level | Override auto-detected pipeline (e.g. `bagel_single_stage`) |
| `stages[].stage_id` | per-stage | Stage identifier (0, 1, ...) |
| `stages[].devices` | per-stage | GPU device IDs (e.g. `"0"`, `"0,1"`) |
| `stages[].max_num_seqs` | per-stage | Maximum concurrent sequences |
| `stages[].gpu_memory_utilization` | per-stage | Fraction of GPU memory to use |
| `stages[].enforce_eager` | per-stage | Disable CUDA graphs |
| `stages[].tensor_parallel_size` | per-stage | TP degree for this stage |
| `stages[].parallel_config.vae_patch_parallel_size` | per-stage (DiT) | VAE tile parallelism degree (DiT stage only) |
| `stages[].vae_use_tiling` | per-stage (DiT) | Enable VAE tiling (required for VAE patch parallel) |
| `connectors` | top-level | Define available connector instances (SHM, Mooncake) |
| `platforms` | top-level | Platform-specific overrides (e.g. `xpu`) |
## FAQ
- If you encounter OOM errors, try decreasing `max_model_len` or `gpu_memory_utilization` in the deploy YAML.
**Two-stage VRAM usage:**
| Stage | VRAM |
| :---- | :--- |
| Stage 0 (Thinker) | **15.04 GiB + KV Cache** |
| Stage 1 (DiT) | **26.50 GiB** |
| Total | **~42 GiB + KV Cache** |
**Single-stage VRAM usage:** The DiT loads the full model (~42 GiB) in one process.
@@ -1,184 +0,0 @@
#!/usr/bin/env python3
"""
Bagel OpenAI-compatible chat client for image generation and multimodal tasks.
Usage:
python openai_chat_client.py --prompt "A cute cat" --output output.png
python openai_chat_client.py --prompt "Describe this image" --image-url https://example.com/image.png
"""
import argparse
import base64
from pathlib import Path
import requests
def generate_image(
prompt: str,
server_url: str = "http://localhost:8091",
image_url: str | None = None,
height: int | None = None,
width: int | None = None,
steps: int | None = None,
seed: int | None = None,
negative_prompt: str | None = None,
modality: str = "text2img", # "text2img" (default), "img2img", "img2text", "text2text"
) -> bytes | str | None:
"""Generate an image or text using the chat completions API.
Args:
prompt: Text description or prompt
server_url: Server URL
image_url: URL or path to input image (for img2img/img2text)
height: Image height in pixels
width: Image width in pixels
steps: Number of inference steps
seed: Random seed
negative_prompt: Negative prompt
modality: Task modality hint
Returns:
Image bytes (for image outputs) or Text string (for text outputs) or None if failed
"""
# Construct Message Content
content = [{"type": "text", "text": f"<|im_start|>{prompt}<|im_end|>"}]
if image_url:
# Check if local file
if Path(image_url).exists():
with open(image_url, "rb") as f:
b64_data = base64.b64encode(f.read()).decode("utf-8")
final_image_url = f"data:image/jpeg;base64,{b64_data}"
else:
final_image_url = image_url
content.append({"type": "image_url", "image_url": {"url": final_image_url}})
messages = [{"role": "user", "content": content}]
# Build request payload with all parameters at top level
# Note: vLLM ignores "extra_body", so we put parameters directly in the payload
payload = {"messages": messages}
# Set output modalities at top level
if modality == "text2img" or modality == "img2img":
payload["modalities"] = ["image"]
elif modality == "img2text" or modality == "text2text":
payload["modalities"] = ["text"]
# Add generation parameters directly to payload
if height is not None:
payload["height"] = height
if width is not None:
payload["width"] = width
if steps is not None:
payload["num_inference_steps"] = steps
if seed is not None:
payload["seed"] = seed
if negative_prompt:
payload["negative_prompt"] = negative_prompt
# Send request
try:
print(f"Sending request to {server_url} with modality {modality}...")
response = requests.post(
f"{server_url}/v1/chat/completions",
headers={"Content-Type": "application/json"},
json=payload,
timeout=300,
)
response.raise_for_status()
data = response.json()
# Extract content - check ALL choices since server may return multiple
# (e.g., text in choices[0], image in choices[1])
choices = data.get("choices", [])
# First pass: look for image output in any choice
for choice in choices:
choice_content = choice.get("message", {}).get("content")
# Handle Image Output
if isinstance(choice_content, list) and len(choice_content) > 0:
first_item = choice_content[0]
if isinstance(first_item, dict) and "image_url" in first_item:
img_url_str = first_item["image_url"].get("url", "")
if img_url_str.startswith("data:image"):
_, b64_data = img_url_str.split(",", 1)
return base64.b64decode(b64_data)
# Second pass: look for text output if no image found
for choice in choices:
choice_content = choice.get("message", {}).get("content")
if isinstance(choice_content, str) and choice_content:
return choice_content
print(f"Unexpected response format: {choices}")
return None
except Exception as e:
print(f"Error: {e}")
return None
def main():
parser = argparse.ArgumentParser(description="Bagel multimodal chat client")
parser.add_argument("--prompt", "-p", default="A cute cat", help="Text prompt")
parser.add_argument("--output", "-o", default="bagel_output.png", help="Output file (for image results)")
parser.add_argument("--server", "-s", default="http://localhost:8091", help="Server URL")
# Modality Control
parser.add_argument("--image-url", "-i", type=str, help="Input image URL or local path")
parser.add_argument(
"--modality",
"-m",
default="text2img",
choices=["text2img", "img2img", "img2text", "text2text"],
help="Task modality",
)
# Generation Params
parser.add_argument("--height", type=int, default=512, help="Image height")
parser.add_argument("--width", type=int, default=512, help="Image width")
parser.add_argument("--steps", type=int, default=25, help="Inference steps")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument("--negative", help="Negative prompt")
args = parser.parse_args()
print(f"Mode: {args.modality}")
if args.image_url:
print(f"Input Image: {args.image_url}")
result = generate_image(
prompt=args.prompt,
server_url=args.server,
image_url=args.image_url,
height=args.height,
width=args.width,
steps=args.steps,
seed=args.seed,
negative_prompt=args.negative,
modality=args.modality,
)
if result:
if isinstance(result, bytes):
# It's an image
output_path = Path(args.output)
output_path.write_bytes(result)
print(f"Image saved to: {output_path}")
print(f"Size: {len(result) / 1024:.1f} KB")
elif isinstance(result, str):
# It's text
print("Response:")
print(result)
else:
print("Failed to generate response")
exit(1)
if __name__ == "__main__":
main()
@@ -1,12 +0,0 @@
#!/bin/bash
# Bagel online serving startup script
MODEL="${MODEL:-ByteDance-Seed/BAGEL-7B-MoT}"
PORT="${PORT:-8091}"
echo "Starting Bagel server..."
echo "Model: $MODEL"
echo "Port: $PORT"
vllm serve "$MODEL" --omni \
--port "$PORT"
@@ -1,164 +0,0 @@
#!/bin/bash
# Bagel multi-stage online serving startup script.
#
# Usage:
# ./run_server_stage_cli.sh --stage 0
# ./run_server_stage_cli.sh --stage 1
# ./run_server_stage_cli.sh --stage 0 -- --tensor-parallel-size 2
# ./run_server_stage_cli.sh --stage 1 -- --gpu-memory-utilization 0.9
#
# By default, `--stage all` keeps the old behavior and launches both stages in
# one session. Use `--stage 0` / `--stage 1` to launch each stage separately in
# different terminal sessions, with stage-specific extra CLI arguments passed
# after `--`.
set -euo pipefail
MODEL="${MODEL:-ByteDance-Seed/BAGEL-7B-MoT}"
PORT="${PORT:-8091}"
MASTER_ADDRESS="${MASTER_ADDRESS:-127.0.0.1}"
MASTER_PORT="${MASTER_PORT:-8092}"
STAGE="all"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_CONFIG="${DEPLOY_CONFIG:-$SCRIPT_DIR/../../../vllm_omni/deploy/bagel.yaml}"
EXTRA_ARGS=()
usage() {
cat <<EOF
Usage: $0 [OPTIONS] [-- EXTRA_VLLM_ARGS...]
Options:
--stage {0|1|all} Stage to launch (default: all)
--model MODEL Model name/path (default: $MODEL)
--port PORT API port for stage 0 (default: $PORT)
--master-address ADDRESS Master/orchestrator address (default: $MASTER_ADDRESS)
--master-port PORT Master/orchestrator port (default: $MASTER_PORT)
--deploy-config PATH Deploy config YAML path (default: $DEPLOY_CONFIG)
--help Show this help message
Examples:
$0 --stage 0
$0 --stage 1
$0 --stage 0 -- --tensor-parallel-size 2
$0 --stage 1 -- --gpu-memory-utilization 0.9
Notes:
- Use different terminal sessions to launch stage 0 and stage 1 separately.
- Extra args after '--' are forwarded only to the selected stage.
- When using '--stage all', the extra args are forwarded to both stages.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--stage)
STAGE="$2"
shift 2
;;
--model)
MODEL="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
--master-address)
MASTER_ADDRESS="$2"
shift 2
;;
--master-port)
MASTER_PORT="$2"
shift 2
;;
--deploy-config)
DEPLOY_CONFIG="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
--)
shift
EXTRA_ARGS=("$@")
break
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
if [[ "$STAGE" != "0" && "$STAGE" != "1" && "$STAGE" != "all" ]]; then
echo "Invalid --stage value: $STAGE" >&2
usage
exit 1
fi
print_config() {
echo "Model: $MODEL"
echo "API Port: $PORT"
echo "Master Address: $MASTER_ADDRESS"
echo "Master Port: $MASTER_PORT"
echo "Deploy Config: $DEPLOY_CONFIG"
echo "Selected Stage: $STAGE"
if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then
echo "Extra Args: ${EXTRA_ARGS[*]}"
fi
}
run_stage_0() {
echo "Starting Stage 0 (Thinker) as master..."
vllm serve "$MODEL" --omni \
--port "$PORT" \
--deploy-config "$DEPLOY_CONFIG" \
--stage-id 0 \
--omni-master-address "$MASTER_ADDRESS" \
--omni-master-port "$MASTER_PORT" \
"${EXTRA_ARGS[@]}"
}
run_stage_1() {
echo "Starting Stage 1 (DiT) in headless mode..."
vllm serve "$MODEL" --omni \
--deploy-config "$DEPLOY_CONFIG" \
--stage-id 1 \
--headless \
--omni-master-address "$MASTER_ADDRESS" \
--omni-master-port "$MASTER_PORT" \
"${EXTRA_ARGS[@]}"
}
echo "Starting Bagel multi-stage server..."
print_config
case "$STAGE" in
0)
run_stage_0
;;
1)
run_stage_1
;;
all)
echo "Launching both stages in one session (legacy mode)..."
echo "Starting Stage 0 (Thinker) in background first..."
run_stage_0 &
STAGE_0_PID=$!
cleanup() {
if [[ -n "${STAGE_0_PID:-}" ]]; then
kill "$STAGE_0_PID" 2>/dev/null || true
wait "$STAGE_0_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
echo "Waiting briefly for Stage 0 to initialize..."
sleep 2
run_stage_1
;;
esac
@@ -1,6 +1,8 @@
# Image-To-Image
This example demonstrates how to deploy Qwen-Image-Edit model for online image editing service using vLLM-Omni.
This example demonstrates how to deploy image-to-image models for online image editing service using vLLM-Omni.
Supported models include Qwen-Image-Edit, BAGEL, and other image-to-image pipelines.
For **multi-image** input editing, use **Qwen-Image-Edit-2509** (QwenImageEditPlusPipeline) and send multiple images in the user message content.
@@ -21,6 +23,12 @@ vllm serve Qwen/Qwen-Image-Edit --omni --port 8092
vllm serve Qwen/Qwen-Image-Edit-2509 --omni --port 8092
```
### BAGEL
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
```
### Start with Parameters
@@ -119,6 +127,16 @@ python openai_chat_client.py --input input.png --prompt "Convert to oil painting
python openai_chat_client.py --input input1.png input2.png --prompt "Combine these images into a single scene" --output output.png
```
Pass model-specific parameters through `--extra-body` (e.g. for BAGEL):
```bash
python openai_chat_client.py \
--input input.png \
--prompt "Make the scene look like a watercolor painting" \
--server http://localhost:8091 \
--extra-body '{"cfg_text_scale": 4.0, "cfg_img_scale": 1.5}'
```
### Method 4: Using Gradio Demo
```bash
@@ -318,6 +336,9 @@ count, use `size` and `n` rather than `height`, `width`, or
| `layers` | int | 4 | Number of layers (Qwen-Image-Layered) |
| `resolution` | int | 640 | Resolution, 640 or 1024 (Qwen-Image-Layered) |
Models like BAGEL accept additional parameters via `extra_body` (e.g.
`cfg_text_scale`, `cfg_img_scale`). See the [BAGEL recipe](../../../recipes/Bagel/BAGEL-7B-MoT.md) for the full list.
## Response Format
```json
@@ -38,6 +38,7 @@ def edit_image(
guidance_scale: float | None = None,
seed: int | None = None,
negative_prompt: str | None = None,
extra_body: dict | None = None,
) -> bytes | None:
"""Edit an image using the chat completions API.
@@ -51,6 +52,7 @@ def edit_image(
guidance_scale: CFG guidance scale
seed: Random seed
negative_prompt: Negative prompt
extra_body: Additional model-specific params (e.g. cfg_text_scale for BAGEL)
Returns:
Edited image bytes or None if failed
@@ -75,24 +77,26 @@ def edit_image(
]
# Build extra_body with generation parameters
extra_body = {}
merged_extra_body: dict[str, object] = {}
if height is not None:
extra_body["height"] = height
merged_extra_body["height"] = height
if width is not None:
extra_body["width"] = width
merged_extra_body["width"] = width
if steps is not None:
extra_body["num_inference_steps"] = steps
merged_extra_body["num_inference_steps"] = steps
if guidance_scale is not None:
extra_body["guidance_scale"] = guidance_scale
merged_extra_body["guidance_scale"] = guidance_scale
if seed is not None:
extra_body["seed"] = seed
merged_extra_body["seed"] = seed
if negative_prompt:
extra_body["negative_prompt"] = negative_prompt
merged_extra_body["negative_prompt"] = negative_prompt
if extra_body:
merged_extra_body.update(extra_body)
# Build request payload
payload = {"messages": messages}
if extra_body:
payload["extra_body"] = extra_body
payload: dict[str, object] = {"messages": messages}
if merged_extra_body:
payload["extra_body"] = merged_extra_body
# Send request
try:
@@ -121,6 +125,19 @@ def edit_image(
return None
def parse_extra_body(value: str) -> dict:
"""Parse a JSON string into a dict for --extra-body."""
import json
try:
obj = json.loads(value)
except json.JSONDecodeError as e:
raise argparse.ArgumentTypeError(f"--extra-body must be valid JSON: {e}") from e
if not isinstance(obj, dict):
raise argparse.ArgumentTypeError("--extra-body must be a JSON object")
return obj
def main():
parser = argparse.ArgumentParser(description="Qwen-Image-Edit chat client")
parser.add_argument("--input", "-i", required=True, nargs="+", help="Input image path(s)")
@@ -133,6 +150,12 @@ def main():
parser.add_argument("--guidance", type=float, default=7.5, help="Guidance scale")
parser.add_argument("--seed", type=int, default=0, help="Random seed")
parser.add_argument("--negative", help="Negative prompt")
parser.add_argument(
"--extra-body",
type=parse_extra_body,
default=None,
help="JSON object merged into request extra_body, e.g. '{\"cfg_text_scale\": 4.0}'.",
)
args = parser.parse_args()
@@ -152,6 +175,7 @@ def main():
guidance_scale=args.guidance,
seed=args.seed,
negative_prompt=args.negative,
extra_body=args.extra_body,
)
if image_bytes:
@@ -120,14 +120,17 @@ Use `/v1/videos/sync` if you want to write the MP4 directly to a file. `POST /v1
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
`VLLM_OMNI_STORAGE_PATH` and `VLLM_OMNI_STORAGE_MAX_CONCURRENCY` are deprecated and will be
removed in a future release; use the names above instead.
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
```
## API Calls
@@ -1,6 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# Default model name
MODEL="${MODEL:-Qwen/Qwen2.5-Omni-7B}"
# Default query type
QUERY_TYPE="${1:-mixed_modalities}"
@@ -162,7 +165,7 @@ echo ""
request_body=$(cat <<EOF
{
"model": "Qwen/Qwen2.5-Omni-7B",
"model": "$MODEL",
"sampling_params_list": $sampling_params_list,
"mm_processor_kwargs": $mm_processor_kwargs,
"modalities": $MODALITIES,
+6 -3
View File
@@ -24,12 +24,15 @@ vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--deploy-config /path/to/your_deploy_config.yaml
```
For the bundled 3x-GPU multi-replica layout (talker/code2wav scale-out),
use:
For a 3x-GPU multi-replica layout (talker/code2wav scale-out on cuda:1,2),
use `--stage-overrides` on top of the default config:
```bash
vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 \
--deploy-config vllm_omni/deploy/qwen3_omni_moe_multi_replicas.yaml
--stage-overrides '{
"1": {"num_replicas": 2, "devices": "1,2"},
"2": {"num_replicas": 2, "devices": "1,2"}
}'
```
### Launch individual stages (stage-based CLI)
@@ -20,7 +20,7 @@ stage_args:
trust_remote_code: true
engine_output_type: text
distributed_executor_backend: "mp"
enable_prefix_caching: false
enable_prefix_caching: true
hf_config_name: thinker_config
tensor_parallel_size: 2
final_output: true
@@ -48,7 +48,7 @@ def generate(
forwarded as top-level fields in the request payload. The serving layer
maps standard keys (height, width, seed, num_inference_steps) to sampling
params and forwards model-specific keys (think, cfg_norm, img_cfg_scale,
…) to ``extra_args`` based on the pipeline's ``EXTRA_BODY_PARAMS``.
…) to ``extra_args`` based on the model extra registry.
Returns:
Image bytes (for image outputs) or text string (for text outputs).
@@ -131,7 +131,7 @@ def main():
parser.add_argument("--width", type=int, default=2048, help="Image width")
parser.add_argument("--num-steps", type=int, default=50, help="Inference steps")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
# Model-specific parameters (forwarded via EXTRA_BODY_PARAMS)
# Model-specific parameters forwarded via the model extra registry.
parser.add_argument("--cfg-scale", type=float, default=4.0, help="CFG scale")
parser.add_argument("--img-cfg-scale", type=float, help="Image CFG scale (img2img)")
parser.add_argument("--cfg-norm", type=str, help="CFG normalization mode")
@@ -87,14 +87,17 @@ curl -X POST http://localhost:8091/v1/videos/sync \
Generated video files are stored on local disk by the async video API.
Local file storage behavior can be controlled via the following environment variables:
- `VLLM_OMNI_STORAGE_PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_STORAGE_MAX_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
- `VLLM_OMNI_SERVER_STORAGE__PATH`: directory used for generated files (default: `/tmp/storage`)
- `VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY`: max concurrent save/delete operations (default: `4`)
`VLLM_OMNI_STORAGE_PATH` and `VLLM_OMNI_STORAGE_MAX_CONCURRENCY` are deprecated and will be
removed in a future release; use the names above instead.
Example:
```bash
export VLLM_OMNI_STORAGE_PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_STORAGE_MAX_CONCURRENCY=8
export VLLM_OMNI_SERVER_STORAGE__PATH=/var/tmp/vllm-omni-videos
export VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY=8
```
## API Calls
+1
View File
@@ -228,6 +228,7 @@ markers = [
"tts: TTS model tests",
"cache: Cache backend tests",
"parallel: Parallelism/distributed tests",
"sp: Sequence parallelism tests",
"example: Doc example code tests",
# platform markers
"cpu: Tests that run on CPU",
+227
View File
@@ -0,0 +1,227 @@
# BAGEL-7B-MoT
> BAGEL image generation through the shared online and offline image examples
## Summary
- Vendor: ByteDance Seed
- Model: `ByteDance-Seed/BAGEL-7B-MoT`
- Task: Text-to-image and image-to-image generation
- Mode: Offline inference and OpenAI-compatible online serving
- Maintainer: Community
## When to use this recipe
Use this recipe when you want to run BAGEL through the shared image examples
instead of model-specific example clients. The generic examples can format
BAGEL text-to-image and image-to-image prompts, select the image output
modality, attach reference images, and forward BAGEL-specific generation
parameters through the pipeline-declared `extra_args` contract.
## References
- Upstream model:
[`ByteDance-Seed/BAGEL-7B-MoT`](https://huggingface.co/ByteDance-Seed/BAGEL-7B-MoT)
- Related offline example:
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
- Related offline image-to-image example:
[`examples/offline_inference/image_to_image/image_edit.py`](../../examples/offline_inference/image_to_image/image_edit.py)
- Related online example:
[`examples/online_serving/text_to_image/openai_chat_client.py`](../../examples/online_serving/text_to_image/openai_chat_client.py)
- Related online image-to-image example:
[`examples/online_serving/image_to_image/openai_chat_client.py`](../../examples/online_serving/image_to_image/openai_chat_client.py)
- Default deploy configs:
[`vllm_omni/deploy/bagel.yaml`](../../vllm_omni/deploy/bagel.yaml),
[`vllm_omni/deploy/bagel_single_stage.yaml`](../../vllm_omni/deploy/bagel_single_stage.yaml)
## Hardware Support
This recipe documents the CUDA layouts used by the in-repo BAGEL deploy
configs. The default two-stage config shares one 80 GB GPU; for more headroom,
move the diffusion stage to a second GPU in a custom deploy config.
## GPU
### 1x A100 80GB
#### Environment
- OS: Linux
- Python: Match the repository requirements for your checkout
- Driver / runtime: NVIDIA CUDA environment with one A100 80 GB GPU
- vLLM version: Match the repository requirements for your checkout
- vLLM-Omni version or commit: Use the commit you are deploying from
#### Offline Commands
Run text-to-image with the shared offline example from the repository root.
Pick the topology with `--stage-configs-path` and forward BAGEL-specific
generation parameters as a JSON object through `--extra-body`:
```bash
# Two-stage (Thinker + DiT), shares one GPU by default
python examples/offline_inference/text_to_image/text_to_image.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--stage-configs-path vllm_omni/deploy/bagel.yaml \
--prompt "A beautiful sunset over mountains" \
--height 1024 \
--width 1024 \
--num-inference-steps 50 \
--negative-prompt "blurry, low quality" \
--seed 42 \
--extra-body '{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_img_scale": 1.5, "cfg_interval": [0.4, 1.0], "cfg_renorm_type": "global", "cfg_renorm_min": 0.0, "think": false}' \
--output /tmp/bagel_text2img.png
```
```bash
# Single-stage (DiT only, with internal LLM/ViT/VAE)
python examples/offline_inference/text_to_image/text_to_image.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--stage-configs-path vllm_omni/deploy/bagel_single_stage.yaml \
--prompt "A beautiful sunset over mountains" \
--height 1024 \
--width 1024 \
--num-inference-steps 50 \
--seed 42 \
--extra-body '{"timestep_shift": 3.0, "cfg_text_scale": 4.0, "cfg_interval": [0.4, 1.0], "cfg_renorm_type": "global", "cfg_renorm_min": 0.0}' \
--output /tmp/bagel_text2img_single.png
```
The `--extra-body` JSON forwards BAGEL-specific parameters into
`OmniDiffusionSamplingParams.extra_args`. Keys are filtered against the model's
declared `extra_body_params` (see
[`vllm_omni/model_extras/bagel.py`](../../vllm_omni/model_extras/bagel.py)), so
unknown keys for BAGEL are silently dropped. This is the preferred way to pass
model-specific params — `timestep_shift` (use `3.0` for 1024×1024; the
deprecated `--timesteps-shift` flag defaults to `1.0` and is NextStep-oriented),
`cfg_text_scale`, `cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`,
`cfg_renorm_min`, and `think`. `--seed` makes generation reproducible.
> **Note**: the two-stage `bagel.yaml` runs both stages on GPU 0. If you hit
> CUDA OOM during warmup, lower stage-0 `gpu_memory_utilization` in the YAML or
> split the stages across two GPUs (see the `2x CUDA GPUs` section).
Run image-to-image with the shared offline image-to-image example:
```bash
python examples/offline_inference/image_to_image/image_edit.py \
--model ByteDance-Seed/BAGEL-7B-MoT \
--prompt "Make the scene look like a watercolor painting" \
--image /tmp/bagel_text2img.png \
--height 512 \
--width 512 \
--extra-args '{"cfg_text_scale": 4.0, "cfg_img_scale": 1.5}' \
--output /tmp/bagel_img2img.png
```
The `--extra-args` JSON forwards BAGEL-specific parameters (e.g. `cfg_text_scale`,
`cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`) into
`OmniDiffusionSamplingParams.extra_args` via the model-extras registry.
#### Online Commands
Start the OpenAI-compatible server:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT --omni --port 8091
```
To use the single-stage topology online:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT \
--omni \
--port 8091 \
--deploy-config vllm_omni/deploy/bagel_single_stage.yaml
```
Send a text-to-image request with BAGEL-specific generation parameters:
```bash
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "<|im_start|>A beautiful sunset over mountains<|im_end|>"}
]
}
],
"modalities": ["image"],
"extra_body": {
"height": 512,
"width": 512,
"num_inference_steps": 50,
"cfg_text_scale": 4.0,
"cfg_img_scale": 1.5,
"negative_prompt": "blurry, low quality",
"seed": 42
}
}'
```
The important part is that BAGEL-specific keys such as `cfg_text_scale`,
`cfg_img_scale`, `cfg_interval`, `cfg_renorm_type`, and `cfg_renorm_min` belong
in `extra_body`. The serving layer routes the declared keys into
`OmniDiffusionSamplingParams.extra_args` for the BAGEL pipeline.
#### Verification
Decode the returned data URL into an image:
```bash
curl http://localhost:8091/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "<|im_start|>A ceramic teapot on a wooden table<|im_end|>"}
]
}
],
"modalities": ["image"],
"extra_body": {
"height": 512,
"width": 512,
"num_inference_steps": 25,
"cfg_text_scale": 4.0,
"seed": 42
}
}' | jq -r '.choices[]?.message.content[]? | select(.image_url.url) | .image_url.url' \
| head -n 1 \
| cut -d',' -f2- \
| base64 -d > /tmp/bagel_online.png
ls -lh /tmp/bagel_online.png
```
### 2x CUDA GPUs
Create a custom deploy config from `vllm_omni/deploy/bagel.yaml` and move the
diffusion stage to GPU 1:
```yaml
stages:
- stage_id: 0
devices: "0"
# keep the remaining stage-0 settings from bagel.yaml
- stage_id: 1
devices: "1"
# keep the remaining stage-1 settings from bagel.yaml
```
Then start serving with that config:
```bash
vllm serve ByteDance-Seed/BAGEL-7B-MoT \
--omni \
--port 8091 \
--deploy-config /path/to/custom_bagel_2gpu.yaml
```
Use the online curl request from the `1x A100 80GB` section to verify that the
server returns an image.
+101
View File
@@ -0,0 +1,101 @@
# GR00T-N1.7
> NVIDIA Isaac GR00T-N1.7-3B robot VLA policy served over the OpenPI WebSocket protocol
## Summary
- Vendor: NVIDIA
- Model: `nvidia/GR00T-N1.7-3B`
- Task: Vision-Language-Action (VLA) inference for robot manipulation
- Mode: Online serving via OpenPI WebSocket endpoint
- Maintainer: timzsu
## When to use this recipe
Use this recipe when you need to serve GR00T-N1.7 as a real-time robot policy
over the OpenPI WebSocket API. It configures the DROID embodiment
(`OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT`) and exposes the standard DROID action
keys (`eef_9d`, `gripper_position`, `joint_position`) with action horizon 40.
## References
- Upstream model: <https://huggingface.co/nvidia/GR00T-N1.7-3B>
- Upstream codebase: <https://github.com/NVIDIA/Isaac-GR00T>
- OpenPI client library: <https://github.com/Physical-Intelligence/openpi>
- Pipeline: `vllm_omni.diffusion.models.gr00t.pipeline_gr00t.Gr00tN1d7Pipeline`
- Deploy config: [`vllm_omni/deploy/Gr00tN1d7.yaml`](../../vllm_omni/deploy/Gr00tN1d7.yaml)
- E2E test: [`tests/e2e/online_serving/test_gr00t_openpi.py`](../../tests/e2e/online_serving/test_gr00t_openpi.py)
## Environment
- OS: Linux
- Python: 3.11+
- Driver / runtime: NVIDIA CUDA
- Hardware: 1 NVIDIA GPU. The upstream model card lists 16 GB+ VRAM for inference (e.g. RTX 4090, L40, H100); in practice this serving path uses ~6 GiB peak (bf16, TP=1, `max_num_seqs: 1`).
- vLLM-Omni version or commit: use versions from your current checkout
## Start server
From repository root:
```bash
vllm serve nvidia/GR00T-N1.7-3B \
--omni \
--host 127.0.0.1 \
--port 8000 \
--served-model-name gr00t-n1d7 \
--stage-configs-path vllm_omni/deploy/Gr00tN1d7.yaml
```
Notes:
- Only `max_num_seqs: 1` is supported (configured in the deploy YAML). The
policy is markovian (`state_history_length=1`, `reset()` returns `{}`) — the
reason for the cap is that `Gr00tPolicy` does its own per-sample
(un)batching inside `get_action` and is not integrated with vLLM's
continuous batching path.
- This pipeline is a thin wrapper around the upstream HF policy
(`AutoModel.from_pretrained` + `enforce_eager`, `tensor_parallel_size=1`,
pipeline `load_weights` is a no-op). The standard diffusion accelerators
(SP, CFG, TeaCache, VAE tiling) do not transfer to a flow-matching action
policy at batch size 1, so a native-kernel port is intentionally out of
scope for this recipe — the value is the OpenPI serving integration, not
kernel-level acceleration.
- The WebSocket endpoint is `ws://127.0.0.1:8000/v1/realtime/robot/openpi`.
The server handshake message (first frame after connect) is a msgpack-encoded
dict with `action_horizon`, `action_keys`, `embodiment_tag`, and
`needs_session_id`.
## Verification
```python
from tests.gr00t.openpi_client_helper import run_policy_session, validate_session_result
validate_session_result(run_policy_session(host="127.0.0.1", port=8000))
```
Or run the e2e test suite:
```bash
python -m pytest tests/e2e/online_serving/test_gr00t_openpi.py -v
```
The test sends a synthetic two-frame DROID observation and checks:
- GR00T metadata contract: `image_resolution`, `action_horizon`, `action_keys`, `embodiment_tag`
- Action shapes: `eef_9d (1,40,9)`, `gripper_position (1,40,1)`, `joint_position (1,40,7)`
- All action values are finite float32
- Reset response is `"reset successful"`
## Notes
- **Do not change the model-specific `policy_server_config` values.** `action_horizon`,
`action_keys`, and `supported_embodiments` are fixed by the GR00T-N1.7 checkpoint and
are validated against the loaded policy at startup (the server refuses to start on a
mismatch). Only `image_resolution` and `needs_session_id` are deployment knobs.
- To switch embodiment, edit `embodiment_tag` under both `model_config` and
`policy_server_config` in `vllm_omni/deploy/Gr00tN1d7.yaml`. Supported values:
`OXE_DROID_RELATIVE_EEF_RELATIVE_JOINT` (default), `XDOF`, `XDOF_SUBTASK`,
`REAL_G1`, `REAL_R1_PRO_SHARPA`, `LIBERO_PANDA`, `SIMPLER_ENV_GOOGLE`,
`SIMPLER_ENV_WIDOWX`.
- GR00T weights are loaded directly by `Gr00tPolicy` via `AutoModel.from_pretrained`;
the pipeline's `load_weights` is intentionally a no-op.
+289
View File
@@ -0,0 +1,289 @@
# Qwen-Image-2512
> Text-to-image serving for the 2512 (Dec-2025) refresh of Qwen-Image
## Summary
- Vendor: Qwen
- Model: `Qwen/Qwen-Image-2512`
- Task: Text-to-image generation
- Mode: Online serving with optional step-wise continuous batching
- Maintainer: Community
## When to use this recipe
Use this recipe when you want to serve the newer `Qwen/Qwen-Image-2512`
checkpoint instead of the original `Qwen/Qwen-Image`. `Qwen-Image-2512` is a
drop-in updated checkpoint that runs on the **same** `QwenImagePipeline`, so the
serving path, flags, and clients are identical to
[`Qwen-Image.md`](./Qwen-Image.md) — only the model id changes. Pick this recipe
to validate the normal online-serving path on a single 80 GB A800, or to run the
validated 2-GPU ModelOpt mixed FP8/NVFP4 quantized variant on B200.
## Difference from Qwen-Image
`Qwen-Image-2512` requires **no code, registry, or pipeline changes** relative to
`Qwen-Image`:
- Same architecture / pipeline: `QwenImagePipeline`
(`vllm_omni/diffusion/registry.py`, see
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)).
- Same serving entrypoint, CLI flags, OpenAI image-generation API, example
clients, and benchmark scripts.
The only change is the **model id**: swap `Qwen/Qwen-Image` for
`Qwen/Qwen-Image-2512` everywhere (serve command, API `model` field, client
`--model`, benchmark `--model`). Everything else in
[`Qwen-Image.md`](./Qwen-Image.md) applies unchanged.
## References
- Sibling recipe (canonical, identical serving path):
[`recipes/Qwen/Qwen-Image.md`](./Qwen-Image.md)
- Upstream or canonical docs:
[`docs/user_guide/examples/online_serving/text_to_image.md`](../../docs/user_guide/examples/online_serving/text_to_image.md)
- Related example under `examples/`:
[`examples/online_serving/text_to_image/README.md`](../../examples/online_serving/text_to_image/README.md)
- Related benchmark:
[`benchmarks/diffusion/diffusion_benchmark_serving.py`](../../benchmarks/diffusion/diffusion_benchmark_serving.py)
- Supported models table:
[`docs/models/supported_models.md`](../../docs/models/supported_models.md)
## Hardware Support
This recipe documents a single-GPU BF16 serving configuration on A800 and a
validated 2-GPU ModelOpt mixed FP8/NVFP4 configuration on B200. Extend it with
more hardware sections as community validation lands.
## GPU
### 1x A800-SXM4-80GB
#### Environment
- OS: Linux
- Python: 3.12.13
- PyTorch: 2.11.0+cu130
- CUDA runtime: 13.0 (driver 580.126.09)
- GPU: NVIDIA A800-SXM4-80GB (81920 MiB)
- vLLM: 0.22.0
- Transformers: 5.8.1
- vLLM-Omni: v0.22.0rc1-70-ge91efc24
#### Command
Start the baseline server:
```bash
vllm serve Qwen/Qwen-Image-2512 --omni --port 8091
```
To enable the step-wise runtime without batching:
```bash
vllm serve Qwen/Qwen-Image-2512 --omni \
--port 8091 \
--step-execution \
--max-num-seqs 1
```
To enable experimental compatible-request batching:
```bash
vllm serve Qwen/Qwen-Image-2512 --omni \
--port 8091 \
--step-execution \
--max-num-seqs 8
```
You can also use the example launcher and pass the model id and extra flags
through:
```bash
bash examples/online_serving/text_to_image/run_server.sh \
--model Qwen/Qwen-Image-2512 --step-execution --max-num-seqs 8
```
#### Verification
Run the existing client example after the server is ready:
```bash
python examples/online_serving/text_to_image/openai_chat_client.py \
--server http://localhost:8091 \
--prompt "A ceramic teapot on a wooden table" \
--output /tmp/qwen_image_2512_recipe.png
```
For a direct API smoke test:
```bash
curl http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen-Image-2512",
"prompt": "A ceramic teapot on a wooden table",
"size": "1024x1024",
"num_inference_steps": 20,
"seed": 42
}'
```
To replay the batching benefit with matched warmup:
```bash
python benchmarks/diffusion/diffusion_benchmark_serving.py \
--endpoint /v1/chat/completions \
--dataset vbench \
--task t2i \
--model Qwen/Qwen-Image-2512 \
--num-prompts 10 \
--height 1024 \
--width 1024 \
--num-inference-steps 50 \
--seed 42 \
--port 8089 \
--max-concurrency 8 \
--warmup-requests 8 \
--warmup-concurrency 8 \
--warmup-num-inference-steps 3 \
--disable-tqdm
```
Run that once against `--max-num-seqs 1`, then rerun it against `--max-num-seqs 8`
and compare the output JSON or terminal metrics.
#### Benchmark
The numbers below are loaded-once request-level measurements, not an HTTP
concurrency benchmark. The model is loaded once, warmup requests are run,
then 100 sequential requests are measured.
```text
Hardware: 1x A800-SXM4-80GB
Requests: 100
Concurrency: 1
Resolution: 1024x1024
Denoising steps: 20
```
| Config | Mean | P95 | P99 | Peak VRAM |
| --- | ---: | ---: | ---: | ---: |
| BF16 | 7.669s | 7.736s | 7.748s | 58,979 MiB |
#### Notes
- Memory usage: BF16 peak VRAM ~59 GiB on A800, leaving ~22 GiB headroom for
compile overhead and image decode.
- Key flags: `--step-execution` enables the step-wise runtime; `--max-num-seqs`
controls how many compatible requests may stay active together.
- Keep `--max-num-seqs 1` when you want the more conservative path, when traffic
is mostly single-request, or when you are debugging correctness before
measuring throughput.
- Current batching is still shape-sensitive: different step progress can
co-batch, but different resolutions do not yet co-batch.
### 2x B200 ModelOpt mixed FP8/NVFP4
#### Environment
- OS: Linux
- Python: 3.12
- Driver / runtime: NVIDIA CUDA environment with two B200 GPUs
- vLLM version: Match the vLLM-Omni checkout used for deployment
- vLLM-Omni version or commit: Use the commit that contains ModelOpt mixed
FP8/NVFP4 checkpoint loading for diffusion models
#### Command
This configuration uses a pre-quantized ModelOpt mixed checkpoint for
Qwen-Image-2512. The validated checkpoint keeps boundary, modulation,
normalization, and output layers in BF16, uses FP8 for sensitive attention
projections, and uses NVFP4 for heavier MLP / attention-output linear layers.
Validated checkpoint:
```text
feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy
```
Start the server with the CUTLASS quantized linear backend:
```bash
CUDA_VISIBLE_DEVICES=2,3 \
vllm serve feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy \
--omni \
--host 0.0.0.0 \
--port 8091 \
--trust-remote-code \
--tensor-parallel-size 2 \
--linear-backend cutlass \
--force-cutlass-fp8
```
#### Verification
Run a direct image generation request after the server is ready:
```bash
curl http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "feizhai123/qwen-image-2512-modelopt-mixed-fp8-sensitive-nvfp4-heavy",
"prompt": "A cinematic Warhammer 40,000 Ultramarine warrior in blue power armor, armor stained with alien ichor, swinging a chainsword at a Tyranid creature, gothic battlefield, dramatic lighting, high detail, no text, no watermark",
"negative_prompt": "blurry, low quality, text, watermark, logo, malformed armor, bad hands",
"size": "1024x1024",
"num_inference_steps": 20,
"true_cfg_scale": 4.0,
"guidance_scale": 4.0,
"seed": 48
}'
```
Check that:
- The server responds on `http://localhost:8091/health`.
- The response contains one generated image.
- Logs show CUTLASS selection for ModelOpt FP8 / mixed-precision linear
layers instead of falling back to the default automatic backend choice.
#### Benchmark
The numbers below are loaded-once request-level measurements, not an HTTP
concurrency benchmark. Each case loads the model once, runs warmup requests,
then measures 100 sequential requests.
```text
Hardware: 2x B200
Tensor parallel size: 2
Requests: 100
Concurrency: 1
Resolution: 1024x1024
Denoising steps: 20
```
| Config | Mean | P90 | P99 | Peak VRAM |
| --- | ---: | ---: | ---: | ---: |
| BF16 | 2.696s | 2.700s | 2.707s | 99,000 MiB |
| ModelOpt FP8 CUTLASS | 2.663s | 2.671s | 2.676s | 85,760 MiB |
| Mixed FP8/NVFP4 CUTLASS | 2.584s | 2.590s | 2.594s | 84,412 MiB |
The same 2-GPU setup was also used to sweep NVFP4 linear backends. Direct
CUTLASS was the fastest validated backend for this checkpoint.
| Backend | Mean | P90 | P99 | Peak VRAM |
| --- | ---: | ---: | ---: | ---: |
| auto | 2.927s | 2.948s | 3.535s | 85,340 MiB |
| flashinfer-cudnn | 3.523s | 3.528s | 3.766s | 84,500 MiB |
| flashinfer-trtllm | 2.711s | 2.727s | 3.010s | 84,996 MiB |
| flashinfer-cutlass | 2.935s | 2.950s | 3.011s | 85,340 MiB |
| cutlass | 2.578s | 2.584s | 2.592s | 84,412 MiB |
#### Notes
- Use `--linear-backend cutlass` to select the validated quantized linear
backend from the CLI.
- `--force-cutlass-fp8` keeps ModelOpt FP8 layers on the CUTLASS FP8 path.
- The benchmark table intentionally reports Qwen-Image-2512 as
concurrency-1 request-level data; do not compare it directly with an online
HTTP concurrency benchmark.
+93 -10
View File
@@ -28,11 +28,9 @@ Smaller 0.6B variants are also available for `CustomVoice` and `Base`.
## References
- Upstream or canonical docs:
[`docs/user_guide/examples/online_serving/qwen3_tts.md`](../../docs/user_guide/examples/online_serving/qwen3_tts.md)
- Related examples under `examples/`:
[`examples/online_serving/qwen3_tts/`](../../examples/online_serving/qwen3_tts/),
[`examples/offline_inference/qwen3_tts/`](../../examples/offline_inference/qwen3_tts/)
[`examples/online_serving/text_to_speech/qwen3_tts/`](../../examples/online_serving/text_to_speech/qwen3_tts/),
[`examples/offline_inference/text_to_speech/qwen3_tts/`](../../examples/offline_inference/text_to_speech/qwen3_tts/)
- Related issue or discussion:
[RFC: add recipes folder](https://github.com/vllm-project/vllm-omni/issues/2645)
@@ -67,9 +65,9 @@ vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-Base \
Alternatively, use the convenience script:
```bash
./examples/online_serving/qwen3_tts/run_server.sh # Default: CustomVoice
./examples/online_serving/qwen3_tts/run_server.sh VoiceDesign # VoiceDesign
./examples/online_serving/qwen3_tts/run_server.sh Base # Base (voice clone)
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh # Default: CustomVoice
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh VoiceDesign # VoiceDesign
./examples/online_serving/text_to_speech/qwen3_tts/run_server.sh Base # Base (voice clone)
```
The bundled deploy config (`vllm_omni/deploy/qwen3_tts.yaml`) enables async
@@ -117,7 +115,7 @@ curl http://localhost:8091/v1/audio/voices
**Using the Python client:**
```bash
cd examples/online_serving/qwen3_tts
cd examples/online_serving/text_to_speech/qwen3_tts
# CustomVoice
python openai_speech_client.py \
@@ -157,8 +155,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \
**Offline inference (no server needed):**
```bash
python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice
python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice --streaming
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-type CustomVoice
python examples/offline_inference/text_to_speech/qwen3_tts/end2end.py --query-type CustomVoice --streaming
```
## Notes
@@ -168,3 +166,88 @@ python examples/offline_inference/qwen3_tts/end2end.py --query-type CustomVoice
- Async chunking: Enabled by default in `qwen3_tts.yaml` for streaming-friendly first-audio latency. Streaming requires `stream=true` with `response_format="pcm"`.
- Task/model matching: Each task type requires its matching model checkpoint. Using a CustomVoice model for a Base (voice clone) request will fail.
- Known limitations: The server serves one model variant at a time. To switch task types (e.g., CustomVoice to Base), restart the server with the corresponding model.
## Hardware Support
## GPU
### 1x RTX 4090 24GB (0.6B CustomVoice)
#### Environment
- OS: Ubuntu 24.04 LTS
- Python: 3.12.3
- PyTorch: 2.11.0+cu130
- Driver / runtime: NVIDIA 580.126.09 / CUDA 13.0
- GPU: NVIDIA GeForce RTX 4090, 24 GB
- vLLM version: 0.21.0
- vLLM-Omni version or commit: 0.21.0rc3.dev91+gd4c13950
#### Command
```bash
vllm serve Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \
--deploy-config vllm_omni/deploy/qwen3_tts.yaml \
--omni --port 8091
```
The default deploy config (`qwen3_tts.yaml`) works without modification on the
RTX 4090. Both stages (talker + code2wav) share GPU 0 with
`gpu_memory_utilization: 0.3` each.
#### Verification
**English synthesis:**
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "Hello, this is Qwen3-TTS running on RTX 4090.",
"voice": "vivian",
"language": "English"
}' --output test_english.wav
```
**Chinese synthesis:**
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "你好,这是在RTX 4090上运行的语音合成测试。",
"voice": "vivian",
"language": "Chinese"
}' --output test_chinese.wav
```
**With emotion instruction:**
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"input": "I am so excited about this!",
"voice": "vivian",
"language": "English",
"instructions": "Speak with great enthusiasm"
}' --output test_emotion.wav
```
#### Notes
- Memory usage: **~13.5 GiB / 24 GiB** at idle with the default deploy config
(`gpu_memory_utilization: 0.3` per stage). The 0.6B model weights occupy only
~2.4 GiB (Stage 0: 1.91 GiB, Stage 1: 0.45 GiB); the remainder is KV cache
pre-allocated at startup. To reduce idle footprint to ~5 GiB, use a custom
deploy config with lower utilization (inference peak ~10 GiB):
```yaml
# Custom deploy config for Qwen3-TTS-12Hz-0.6B on RTX 4090
# Copy vllm_omni/deploy/qwen3_tts.yaml and override gpu_memory_utilization:
stages:
- stage_id: 0
gpu_memory_utilization: 0.15
- stage_id: 1
gpu_memory_utilization: 0.15
```
+12 -2
View File
@@ -12,6 +12,13 @@ that is aligned with
that external repository as a reference for structure rather than the place to
add files for this repo. Use one Markdown file per model family by default.
> **Note:** The vLLM-Omni TTS recipes (12 models across 8 providers) are also
> published on the rendered recipes site at
> [recipes.vllm.ai](https://recipes.vllm.ai) (see
> [vllm-project/recipes#554](https://github.com/vllm-project/recipes/pull/554)).
> Individual pages resolve at `https://recipes.vllm.ai/<provider>/<model-id>`,
> e.g. [`bosonai/higgs-audio-v3-tts-4b`](https://recipes.vllm.ai/bosonai/higgs-audio-v3-tts-4b).
Example layout:
```text
@@ -29,11 +36,13 @@ recipes/
|--------|------|----------|
| [`audiox/AudioX.md`](./audiox/AudioX.md) | Offline + online unified text/video→audio diffusion | 1x L4 24GB |
| [`Baidu/ERNIE-Image.md`](./Baidu/ERNIE-Image.md) | Text-to-image online serving (ERNIE-Image 8B) | 1x or 2x RTX 4090 24GB |
| [`Bagel/BAGEL-7B-MoT.md`](./Bagel/BAGEL-7B-MoT.md) | Text-to-image with shared online/offline examples | 1x A100 80GB / 2x CUDA GPUs |
| [`BosonAI/Higgs-Audio-V3-TTS.md`](./BosonAI/Higgs-Audio-V3-TTS.md) | Online + offline multilingual TTS with voice cloning | 1x H100 80GB |
| [`ByteDance/Lance.md`](./ByteDance/Lance.md) | Unified AR+diffusion: text/img/video gen + understanding (Lance 3B) | 1x B300 / A100 80GB |
| [`fishaudio/Fish-Speech-S2-Pro.md`](./fishaudio/Fish-Speech-S2-Pro.md) | Online serving for TTS | 1x A800 80GB |
| [`Helios/Helios.md`](./Helios/Helios.md) | Text-to-video, image-to-video, and video-to-video generation | 1x NVIDIA H20 |
| [`inclusionAI/Ming-flash-omni-2.0.md`](./inclusionAI/Ming-flash-omni-2.0.md) | Online serving for multimodal chat + standalone TTS | 4x H100 / 1x H100 80GB |
| [`inclusionAI/Ming-omni-tts-0.5B.md`](./inclusionAI/Ming-omni-tts-0.5B.md) | Offline + online dense Ming TTS/audio generation | 1x AMD `gfx942` (ROCm 7.2) |
| [`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) |
| [`LTX/LTX-2.md`](./LTX/LTX-2.md) | Text-to-video and image-to-video serving | 1x H200 141GB |
| [`LTX/LTX-2.3.md`](./LTX/LTX-2.3.md) | Text-to-video with audio generation (22B) | 1x GPU (96GB VRAM) |
| [`mistralai/Voxtral-TTS.md`](./mistralai/Voxtral-TTS.md) | Online serving for TTS | 1x RTX 4090 24GB |
@@ -41,8 +50,9 @@ recipes/
| [`cosmos3/Cosmos3-Super.md`](./cosmos3/Cosmos3-Super.md) | 64B T2I / T2V / I2V / V2V generation (+ optional audio) / Action policy | 8x H200/H100/A100 / 2x H200 / B300 |
| [`OpenBMB/MiniCPM-o-4_5.md`](./OpenBMB/MiniCPM-o-4_5.md) | Online serving for omni multimodal chat (text / image / audio / video → text + 24 kHz speech) | 2x A100/H100 80GB / 3x mid-tier GPU / 8x RTX 4090 24GB |
| [`OpenBMB/VoxCPM2.md`](./OpenBMB/VoxCPM2.md) | Online + offline TTS with native AR pipeline (48 kHz, 30+ languages) | 1x RTX 4090 24GB |
| [`OpenMOSS/MOSS-TTS.md`](./OpenMOSS/MOSS-TTS.md) | Online + offline multilingual TTS (MOSS-TTS family, 8B) | 1x H100 80GB |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt mixed FP8/NVFP4 | 1x A100 80GB / 2x B200 |
| [`Qwen/Qwen-Image.md`](./Qwen/Qwen-Image.md) | Text-to-image serving with step-wise continuous batching replay | 1x A100 80GB |
| [`Qwen/Qwen-Image-2512.md`](./Qwen/Qwen-Image-2512.md) | Text-to-image serving with step-wise continuous batching replay and ModelOpt FP8 / mixed FP8/NVFP4 | 1x A800 80GB / 2x B200 |
| [`Qwen/Qwen-Image-Edit.md`](./Qwen/Qwen-Image-Edit.md) | Text-guided single-image editing | 1x or 2x H200 141GB |
| [`Qwen/Qwen3-Omni.md`](./Qwen/Qwen3-Omni.md) | Online serving for multimodal chat | 1x A100 80GB |
| [`Qwen/Qwen3-TTS.md`](./Qwen/Qwen3-TTS.md) | Text-to-speech serving (CustomVoice / VoiceDesign / Base) | 1x H100/A100 80GB |
+25
View File
@@ -72,6 +72,31 @@ pytest -s -v tests/e2e/offline_inference/test_sensenova_u1_text2img.py \
- No deploy YAML needed — the engine auto-generates a single-stage diffusion config.
- Think mode (`--think`) is recommended for higher image quality.
#### Text-to-Image via the shared example
SenseNova-U1 is also registered with the shared text-to-image example, so you
can run it through `text_to_image.py` instead of `end2end.py`. Forward the
SenseNova-specific generation parameters as a JSON object through `--extra-body`:
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model SenseNova/SenseNova-U1-8B-MoT \
--prompt "A beautiful sunset over mountains" \
--width 2048 --height 2048 \
--num-inference-steps 50 \
--seed 42 \
--extra-body '{"think": true, "cfg_scale": 4.0, "cfg_norm": "none", "timestep_shift": 3.0, "t_eps": 0.02}' \
--output sensenova_text2img.png
```
The `--extra-body` keys are filtered against the model's declared
`extra_body_params` (see
[`vllm_omni/model_extras/sensenova_u1.py`](../../vllm_omni/model_extras/sensenova_u1.py)):
`think`, `cfg_scale`, `cfg_norm`, `timestep_shift`, `t_eps`, `img_cfg_scale`,
and `max_tokens`. No deploy YAML is needed — the engine auto-generates a
single-stage diffusion config. For richer task modalities (img2text, text2text)
and CPU offload, use the dedicated `end2end.py` script described above.
#### Image-to-Image Editing (img2img)
```bash
+123
View File
@@ -0,0 +1,123 @@
# Stable Diffusion XL
> Text-to-image generation with Stability AI's SDXL Base 1.0
## Summary
- Vendor: Stability AI
- Model: `stabilityai/stable-diffusion-xl-base-1.0`
- Task: Text-to-image generation
- Mode: Offline inference / Online serving with the OpenAI-compatible images API
- Maintainer: Community
## When to use this recipe
Use this recipe when you want a high-quality, open-weight text-to-image model
that generates 1024x1024 images. SDXL Base 1.0 is one of the most widely used
diffusion models and produces photorealistic outputs with strong prompt
adherence. It requires ~6.5 GiB for model weights and runs inference at
~7.5 seconds per image (30 steps) on a single Intel Data Center GPU.
## References
- Model: <https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0>
- Architecture reference: <https://github.com/Stability-AI/generative-models>
- Related example under `examples/`:
[`examples/offline_inference/text_to_image/text_to_image.py`](../../examples/offline_inference/text_to_image/text_to_image.py)
## Hardware Support
## GPU
### 1x Intel Arc BMG GPU (XPU)
#### Environment
- OS: Linux
- Python: 3.10+
- Driver / runtime: Intel XPU environment with Intel Arc BMG GPU
- vLLM version: Match the repository requirements for your checkout
- vLLM-Omni version or commit: Use the commit you are deploying from
#### Command
**Offline inference:**
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model stabilityai/stable-diffusion-xl-base-1.0 \
--prompt "a beautiful sunset over the ocean, photorealistic" \
--height 1024 --width 1024 \
--num-inference-steps 30 \
--guidance-scale 7.5 \
--seed 42 \
--output sdxl_output.png
```
**Online serving:**
```bash
vllm serve stabilityai/stable-diffusion-xl-base-1.0 --omni --port 8091
```
#### Verification
**For offline inference:** Check the output image file exists and has valid content.
**For online serving:**
```bash
curl -X POST http://localhost:8091/v1/images/generations \
-H "Content-Type: application/json" \
-d '{
"model": "stabilityai/stable-diffusion-xl-base-1.0",
"prompt": "a cute cat sitting on a windowsill, highly detailed, sharp focus",
"negative_prompt": "blurry, out of focus, low quality",
"guidance_scale": 7.5,
"n": 1,
"size": "1024x1024"
}' | python3 -c "
import json, base64, sys
data = json.load(sys.stdin)
with open('output.png', 'wb') as f:
f.write(base64.b64decode(data['data'][0]['b64_json']))
print('saved output.png')
"
```
#### Notes
- Memory usage: Model weights ~6.5 GiB, peak memory ~10.9 GiB at 1024x1024 with CFG.
- Generation time: ~7.5 seconds for 30 inference steps at 1024x1024.
- `negative_prompt` requires `guidance_scale > 1` to take effect. Recommended range: 5.0-9.0.
- Default resolution is 1024x1024. Other supported sizes: 512x512, 768x768.
- The model uses dual CLIP text encoders (ViT-L/14 + OpenCLIP ViT-bigG/14) for rich text understanding.
### 1x NVIDIA GPU (24+ GB VRAM)
#### Environment
- OS: Linux
- Python: 3.10+
- Driver / runtime: NVIDIA CUDA environment with an A100
- vLLM version: Match the repository requirements for your checkout
- vLLM-Omni version or commit: Use the commit you are deploying from
#### Command
```bash
python examples/offline_inference/text_to_image/text_to_image.py \
--model stabilityai/stable-diffusion-xl-base-1.0 \
--prompt "a beautiful sunset over the ocean, photorealistic" \
--height 1024 --width 1024 \
--num-inference-steps 30 \
--guidance-scale 7.5 \
--seed 42 \
--output sdxl_output.png
```
#### Notes
- Memory usage: ~6.5 GiB model weights, ~10 GiB peak with CFG at 1024x1024.
- Fits on a single GPU with 24 GB VRAM (e.g., RTX 3090/4090, A5000).
- For GPUs with less VRAM, use `--enable-cpu-offload` to offload text encoders during diffusion.
+48 -1
View File
@@ -47,6 +47,10 @@ mode is selected per request:
**`nvidia/Cosmos3-Nano-Policy-DROID`** is served the same way
(`domain_name=droid_lerobot`).
- **DROID OpenPI policy server** — serve `nvidia/Cosmos3-Nano-Policy-DROID` and
connect an OpenPI-compatible websocket client to `/v1/realtime/robot/openpi`.
This path returns action chunks directly instead of an mp4.
Action requests can use `input_reference` or `video_reference` for video input.
`policy` and `forward_dynamics` can also use an image reference; `inverse_dynamics`
requires a video reference.
@@ -101,7 +105,10 @@ vllm serve nvidia/Cosmos3-Nano \
To run **without** guardrails (you are responsible for license compliance),
add `--no-guardrails` (no token/`cosmos-guardrail` needed). For extra GPUs use
`--ulysses-degree N` (context parallel) or `--tensor-parallel-size N`;
`--enable-layerwise-offload` reduces VRAM on smaller GPUs. The pipeline
`--enable-layerwise-offload` reduces VRAM on smaller GPUs;
`--quantization fp8` (online, no calibration) cuts peak VRAM for 720p video
generation from ~50 GB to ~36 GB with BF16-level quality (T2V composition can
shift at the same seed). The pipeline
auto-resolves from `model_index.json`; pass
`--model-class-name Cosmos3OmniDiffusersPipeline` to force it explicitly.
@@ -245,6 +252,39 @@ VIDEO_ID=$(curl -sS -X POST http://localhost:8000/v1/videos \
# poll until status == completed, then:
curl -sS "http://localhost:8000/v1/videos/$VIDEO_ID" | jq '.action | {shape, dtype, raw_action_dim, domain_id}'
curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inverse_dynamics.mp4
# DROID OpenPI policy server (websocket action serving).
# Requires cosmos_framework on PYTHONPATH because the pipeline reuses the
# reference RoboLab action transforms. If your checkpoint config already
# includes policy_server_config, omit the stage_overrides file and flag.
cat > cosmos3_droid_openpi_stage_overrides.json <<'JSON'
{
"0": {
"model_config": {
"policy_server_config": {
"image_resolution": [540, 640],
"n_external_cameras": 2,
"needs_wrist_camera": true,
"needs_stereo_camera": false,
"needs_session_id": true,
"action_space": "joint_position"
}
}
}
}
JSON
vllm serve nvidia/Cosmos3-Nano-Policy-DROID \
--omni \
--host 0.0.0.0 --port 8000 \
--model-class-name Cosmos3OmniDiffusersPipeline \
--no-guardrails \
--stage-overrides "$(cat cosmos3_droid_openpi_stage_overrides.json)"
# Point an OpenPI websocket client at:
# ws://localhost:8000/v1/realtime/robot/openpi
# The first server message is policy_server_config. Each infer request sends a
# msgpack-numpy observation dict and receives a writable float32 action array.
```
#### Notes
@@ -272,6 +312,13 @@ curl -sS -L "http://localhost:8000/v1/videos/$VIDEO_ID/content" -o cosmos3_inver
For V2V, `condition_frame_indexes_vision` selects the clean conditioned latent
frame indexes (default `[0, 1]`), and `condition_video_keep` selects whether the
API decodes the first or last needed reference frames (`"first"` by default).
- **DROID OpenPI observations:** include a string `prompt`, either
`observation/image` or the three-view DROID camera keys
(`observation/wrist_image_left`, `observation/exterior_image_1_left`,
`observation/exterior_image_2_left`), plus `observation/gripper_position` and
`observation/joint_position`. Optional extra params include `history_length`,
`conditioning_fps`, `action_chunk_size`, `raw_action_dim`, `deterministic_seed`,
and `session_id`.
- **Known limitations:**
- Guardrails-on requires `cosmos-guardrail` **and** access to the gated
`nvidia/Cosmos-1.0-Guardrail` repo (accept license + `HF_TOKEN`); otherwise
+42
View File
@@ -105,3 +105,45 @@ voice-cloning e2e test ([`Voice cloning (capability gated upstream)`](../../exam
) or adding a `ref_audio` request parameter will fail
because the public checkpoint does not provide the encoder weights needed to
convert reference audio into conditioning features. The expected failure is: `RuntimeError: encode_waveforms requires encoder weights which are not available in the open-source checkpoint.`
### 1x NVIDIA RTX A6000 48GB
#### Environment
- OS: Ubuntu 22.04
- Python: 3.12
- Driver / runtime: NVIDIA driver 580.126.09, CUDA 13.0
- GPU: NVIDIA RTX A6000, 48 GB
- vLLM version: 0.22.0
- vLLM-Omni version or commit: 0.22.0rc2.dev8+g41a795b5d
- Required package: `pip install mistral-common`
#### Command
```bash
vllm serve mistralai/Voxtral-4B-TTS-2603 --omni --port 8091
```
#### Verification
```bash
curl -X POST http://localhost:8091/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "mistralai/Voxtral-4B-TTS-2603",
"input": "Artificial intelligence is transforming the way we interact with technology.",
"voice": "neutral_female",
"response_format": "wav"
}' -o voxtral_test.wav
```
Supported voice presets: `ar_male`, `casual_female`, `casual_male`, `cheerful_female`,
`de_female`, `de_male`, `es_female`, `es_male`, `fr_female`, `fr_male`, `hi_female`,
`hi_male`, `it_female`, `it_male`, `neutral_female`, `neutral_male`, `nl_female`,
`nl_male`, `pt_female`, `pt_male`.
#### Notes
- Memory usage: Peak VRAM ~40.5 GiB during inference.
- Generation time: ~8 seconds for ~200 words of English text.
- Audio quality is high; mono 24 kHz output.
+3
View File
@@ -27,3 +27,6 @@ prettytable>=3.8.0
aenum==3.1.16
pyzmq>=25.0.0
janus>=1.0.0
msgpack>=1.0.0
pydantic>=2.1.0
pydantic-settings>=2.0.0
+31
View File
@@ -14,6 +14,37 @@ from vllm_omni.benchmarks.patch.patch import MixRequestFuncOutput
pytestmark = [pytest.mark.core_model, pytest.mark.benchmark, pytest.mark.cpu]
def test_tpot_matches_mean_itl_per_request():
"""TPOT and pooled ITL should agree when output_len tracks ITL samples."""
output = MixRequestFuncOutput()
output.success = True
output.prompt_len = 100
# Simulate server reporting more tokens than SSE chunks (bundled tokens).
# len(itl)=2 → 2 inter-chunk intervals, but server generated 10 tokens.
output.output_tokens = 10
output.generated_text = "hello world"
output.ttft = 0.05
output.text_latency = 0.25
output.latency = 0.30
output.itl = [0.10, 0.10]
metrics, _ = calculate_metrics(
input_requests=[],
outputs=[output],
dur_s=1.0,
tokenizer=None,
selected_percentiles=[50.0],
goodput_config_dict={},
task_type=TaskType.GENERATION,
selected_percentile_metrics=["tpot", "itl"],
max_concurrency=None,
request_rate=float("inf"),
benchmark_duration=1.0,
)
assert metrics.mean_tpot_ms == pytest.approx(metrics.mean_itl_ms, rel=1e-6, abs=1e-6)
def _make_output(prompt_len: int, output_tokens: int = 10) -> MixRequestFuncOutput:
"""Build a minimal successful MixRequestFuncOutput for metrics aggregation."""
output = MixRequestFuncOutput()
+165 -2
View File
@@ -133,7 +133,9 @@ async def test_output_tokens_not_assigned_without_metrics(mocker: MockerFixture)
# Assert
assert output.success is True
assert output.output_tokens == 0, "output_tokens should default to 0 when no metrics"
# Without server metrics we no longer infer count from SSE chunks because
# one chunk may carry multiple tokens. output_tokens stays 0 here.
assert output.output_tokens == 0, "output_tokens should be 0 when no server metrics are present"
assert output.generated_text == "Hello world"
@@ -277,7 +279,7 @@ async def test_output_tokens_with_missing_num_tokens_out(mocker: MockerFixture):
# Assert
assert output.success is True
assert output.output_tokens == 0, "output_tokens should default to 0 when num_tokens_out is missing"
assert output.output_tokens == 0, "output_tokens should be 0 when num_tokens_out is missing"
@pytest.mark.asyncio
@@ -579,6 +581,167 @@ class TestTextLatencyAttribute:
# ============================================================================
@pytest.mark.asyncio
async def test_skips_empty_role_chunk_for_ttft_and_itl(mocker: MockerFixture):
"""Role/empty text chunks must not count toward TTFT or ITL."""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"role": "assistant", "content": ""}}],
"modality": "text",
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": " world"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks, delay_between_chunks=0.01)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 2
assert len(output.itl) == 1
assert output.ttft > 0
assert output.text_latency - output.ttft == pytest.approx(sum(output.itl), rel=1e-6, abs=1e-6)
@pytest.mark.asyncio
async def test_bundled_tokens_split_itl_from_usage(mocker: MockerFixture):
"""Multiple tokens in one SSE chunk should expand ITL via usage deltas."""
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "A"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11},
}
),
create_sse_chunk(
{
"choices": [{"delta": {"content": "BCD"}}],
"modality": "text",
"usage": {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks, delay_between_chunks=0.02)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 4
assert len(output.itl) == 3
assert output.text_latency - output.ttft == pytest.approx(sum(output.itl), rel=1e-6, abs=1e-6)
@pytest.mark.asyncio
async def test_output_tokens_prefers_usage_over_metrics(mocker: MockerFixture):
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Hello"}}],
"modality": "text",
"metrics": {"num_tokens_out": 5},
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 8
@pytest.mark.asyncio
async def test_metrics_only_chunk_updates_output_tokens(mocker: MockerFixture):
request_input = RequestFuncInput(
model="test-model",
model_name="test-model",
prompt="test prompt",
api_url="http://test.com/v1/chat/completions",
prompt_len=10,
output_len=20,
)
chunks = [
create_sse_chunk(
{
"choices": [{"delta": {"content": "Text response"}}],
"modality": "text",
}
),
create_sse_chunk(
{
"modality": "text",
"metrics": {"num_tokens_out": 25, "num_tokens_in": 10},
}
),
b"data: [DONE]\n\n",
]
mock_response = MockResponse(200, chunks)
mock_session = mocker.AsyncMock()
mock_session.post = mocker.MagicMock(return_value=mock_response)
output = await async_request_openai_chat_omni_completions(request_input, mock_session)
assert output.success is True
assert output.output_tokens == 25
@pytest.mark.asyncio
async def test_prompt_len_assigned_from_usage(mocker: MockerFixture):
# Arrange: request claims prompt_len=100, but server reports 4992 (multimodal).
@@ -523,6 +523,7 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni
"Qwen/Qwen-Image-Edit",
True,
id="image-to-image-dalle-endpoint",
marks=pytest.mark.skip(reason="Temporarily disabled due to failure."),
),
pytest.param(
ServerCase(
+84
View File
@@ -0,0 +1,84 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for ServerSettings env-var parameterization."""
import pytest
from vllm_omni.config.server_settings import ServerSettings
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
ENV_VARS = [
"VLLM_OMNI_SERVER_STORAGE__PATH",
"VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY",
"VLLM_OMNI_SERVER_STORAGE__FILE_TTL",
"VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL",
"VLLM_OMNI_STORAGE_PATH",
"VLLM_OMNI_STORAGE_MAX_CONCURRENCY",
]
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for name in ENV_VARS:
monkeypatch.delenv(name, raising=False)
def test_defaults_when_no_env_set():
storage = ServerSettings().storage
assert storage.path == "/tmp/storage"
assert storage.file_concurrency == 4
assert storage.file_ttl is None
assert storage.ttl_sweep_interval is None
def test_nested_env_vars_populate_storage(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__PATH", "/var/storage")
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_CONCURRENCY", "8")
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_TTL", "120")
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__TTL_SWEEP_INTERVAL", "30")
storage = ServerSettings().storage
assert storage.path == "/var/storage"
assert storage.file_concurrency == 8
assert storage.file_ttl == 120
assert storage.ttl_sweep_interval == 30
def test_ttl_sweep_interval_defaults_when_ttl_set(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__FILE_TTL", "60")
storage = ServerSettings().storage
assert storage.file_ttl == 60
assert storage.ttl_sweep_interval == 300
def test_deprecated_path_alias_populates_and_warns(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_STORAGE_PATH", "/legacy/path")
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_PATH"):
storage = ServerSettings().storage
assert storage.path == "/legacy/path"
def test_deprecated_concurrency_alias_populates_and_warns(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_STORAGE_MAX_CONCURRENCY", "16")
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_MAX_CONCURRENCY"):
storage = ServerSettings().storage
assert storage.file_concurrency == 16
def test_new_env_var_wins_over_deprecated_alias(monkeypatch):
monkeypatch.setenv("VLLM_OMNI_SERVER_STORAGE__PATH", "/new/path")
monkeypatch.setenv("VLLM_OMNI_STORAGE_PATH", "/legacy/path")
with pytest.warns(DeprecationWarning, match="VLLM_OMNI_STORAGE_PATH"):
storage = ServerSettings().storage
assert storage.path == "/new/path"
@@ -0,0 +1,133 @@
"""Tests for OmniGenerationScheduler update_from_output finish conditions.
The three-way finish condition in update_from_output determines when a
generation request (e.g. Code2Wav) is marked FINISHED_STOPPED:
1. No adapter (sync mode): finish when all prompt tokens are computed.
2. Adapter present, adapter done: finish when computed AND adapter signals done.
3. Adapter present, adapter NOT done: do NOT finish — wait for next chunk.
These cases are the core correctness guarantee for async_chunk batch mode.
Without condition #3, a request finishes prematurely after processing
only the first chunk, producing truncated audio.
"""
import unittest
from vllm.v1.request import RequestStatus
class FakeRequest:
"""Minimal request for testing finish conditions."""
def __init__(self, request_id: str, prompt_len: int, num_computed: int):
self.request_id = request_id
self.prompt_token_ids = list(range(prompt_len))
self.num_computed_tokens = num_computed
self.status = RequestStatus.RUNNING
self.stop_reason = None
self.sampling_params = None
self.num_cached_tokens = 0
self.num_output_tokens = 0
self.num_output_placeholders = 0
self.num_external_computed_tokens = 0
self.trace_headers = None
self.num_nans_in_logits = 0
self.client_index = 0
def is_finished(self):
return self.status in (
RequestStatus.FINISHED_STOPPED,
RequestStatus.FINISHED_LENGTH_CAPPED,
RequestStatus.FINISHED_ABORTED,
RequestStatus.FINISHED_ERROR,
)
def get_finished_reason(self):
return "stop"
def take_events(self):
return None
class FakeChunkAdapter:
"""Minimal chunk transfer adapter mock."""
def __init__(self, finished_request_ids: set[str] | None = None):
self.finished_requests = finished_request_ids or set()
def cleanup(self, req_id, external_req_id=None):
pass
def _evaluate_finish_condition(request, chunk_transfer_adapter):
"""Extract the three-way finish logic from update_from_output."""
_all_computed = request.num_computed_tokens >= len(request.prompt_token_ids)
_adapter_done = (
chunk_transfer_adapter is not None and request.request_id in chunk_transfer_adapter.finished_requests
)
return (
request.status == RequestStatus.FINISHED_STOPPED
or (_all_computed and chunk_transfer_adapter is None)
or (_all_computed and _adapter_done)
)
class TestFinishConditionSyncMode(unittest.TestCase):
"""Case 1: No adapter (sync mode) — finish when all tokens computed."""
def test_finishes_when_all_computed(self):
req = FakeRequest("r1", prompt_len=100, num_computed=100)
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
def test_does_not_finish_when_partially_computed(self):
req = FakeRequest("r1", prompt_len=100, num_computed=50)
assert not _evaluate_finish_condition(req, chunk_transfer_adapter=None)
class TestFinishConditionAsyncAdapterDone(unittest.TestCase):
"""Case 2: Adapter present AND adapter signals done — finish."""
def test_finishes_when_computed_and_adapter_done(self):
req = FakeRequest("r1", prompt_len=100, num_computed=100)
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
assert _evaluate_finish_condition(req, adapter)
def test_does_not_finish_when_computed_but_adapter_not_done(self):
"""Case 3: Adapter present but NOT done — must NOT finish."""
req = FakeRequest("r1", prompt_len=100, num_computed=100)
adapter = FakeChunkAdapter(finished_request_ids=set())
assert not _evaluate_finish_condition(req, adapter)
class TestFinishConditionAsyncNotComputed(unittest.TestCase):
"""Adapter present, tokens not all computed — never finishes."""
def test_does_not_finish_partial_compute_adapter_done(self):
req = FakeRequest("r1", prompt_len=100, num_computed=50)
adapter = FakeChunkAdapter(finished_request_ids={"r1"})
assert not _evaluate_finish_condition(req, adapter)
def test_does_not_finish_partial_compute_adapter_not_done(self):
req = FakeRequest("r1", prompt_len=100, num_computed=50)
adapter = FakeChunkAdapter(finished_request_ids=set())
assert not _evaluate_finish_condition(req, adapter)
class TestFinishConditionAlreadyStopped(unittest.TestCase):
"""Request already FINISHED_STOPPED — always returns True."""
def test_already_stopped_no_adapter(self):
req = FakeRequest("r1", prompt_len=100, num_computed=0)
req.status = RequestStatus.FINISHED_STOPPED
assert _evaluate_finish_condition(req, chunk_transfer_adapter=None)
def test_already_stopped_with_adapter(self):
req = FakeRequest("r1", prompt_len=100, num_computed=0)
req.status = RequestStatus.FINISHED_STOPPED
adapter = FakeChunkAdapter(finished_request_ids=set())
assert _evaluate_finish_condition(req, adapter)
if __name__ == "__main__":
unittest.main()
@@ -556,6 +556,10 @@ def benchmark_params(request, diffusion_server):
# ---------------------------------------------------------------------------
_STAGE_METRICS_ENDPOINTS = {"/v1/chat/completions"}
_DIFFUSION_PIPELINE_PROFILER_ARG = "enable-diffusion-pipeline-profiler"
def run_benchmark(
host: str,
port: int,
@@ -608,6 +612,11 @@ def run_benchmark(
str(tmp_result_file),
]
serve_args_dict = (server_cfg or {}).get("serve_args_dict")
profiler_enabled = isinstance(serve_args_dict, dict) and bool(serve_args_dict.get(_DIFFUSION_PIPELINE_PROFILER_ARG))
if endpoint in _STAGE_METRICS_ENDPOINTS and profiler_enabled:
cmd.append("--return-stage-metrics")
for key, value in params.items():
if key in exclude_keys or value is None:
continue
@@ -41,7 +41,11 @@
"tensor_parallel_size": 2,
"hf_overrides": {
"rope_parameters": {
"mrope_section": [0, 32, 32],
"mrope_section": [
0,
32,
32
],
"rope_type": "default"
}
},
@@ -58,7 +62,9 @@
"max_tokens": 8192,
"detokenize": true,
"skip_special_tokens": false,
"stop_token_ids": [128025]
"stop_token_ids": [
128025
]
}
},
{
@@ -106,7 +112,9 @@
"content": [
{
"prompt": "新年宠物海报,Q版圆润的可爱标题“新年快乐汪”,副标题“HAPPY NEW YEAR”。 鱼眼镜头,背景是房间门口,近景,上传的主体歪头笑,围着红色围巾,戴着红色毛线帽,高清,绒毛细节,面部特写。 宝丽莱相纸,超现实主义,写实主义,胶片摄影,打印颗粒感肌理。肌理,超写实,复古感。",
"image_urls": ["https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png"],
"image_urls": [
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_0_0.png"
],
"num_inference_steps": 8,
"seed": 42,
"guidance_scale": 1.0
@@ -119,10 +127,9 @@
"num-prompts": 96,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.1,
"latency_p99": 44.46,
"latency_mean": 35.57,
"latency_std_dev": 0.5,
"throughput_qps": 0.1874,
"latency_p99": 101.852,
"latency_mean": 79.0385,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
}
@@ -23,10 +23,11 @@
"max-concurrency": 1,
"skip-performance-assertion": true,
"baseline": {
"throughput_qps": 0.21,
"latency_p99": 4.9235,
"peak_memory_mb_max": 57576,
"peak_memory_mb_mean": 57576
"throughput_qps": 0.213,
"latency_mean": 4.6953,
"latency_p99": 4.8601,
"peak_memory_mb_max": 56912.0,
"peak_memory_mb_mean": 56912.0
}
}
]
@@ -32,25 +32,25 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
135.4379,
187.0896,
400,
300,
400,
600,
900
435.675,
724.6836
],
"mean_e2el_ms": [
26000,
43000,
52000,
72000,
109000
17619.9851,
34811.8335,
43656.9501,
56657.1548,
85097.6016
],
"mean_audio_rtf": [
0.15,
0.30,
0.35,
0.50,
0.75
0.1279,
0.2238,
0.2735,
0.3712,
0.5634
]
}
},
@@ -79,13 +79,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
600
340.329
],
"mean_e2el_ms": [
6000
4305.477
],
"mean_audio_rtf": [
0.20
0.1305
]
}
},
@@ -116,13 +116,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
300
220.0909
],
"mean_e2el_ms": [
7000
5475.9565
],
"mean_audio_rtf": [
0.25
0.1818
]
}
},
@@ -155,13 +155,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
700
450.0155
],
"mean_e2el_ms": [
11000
8516.8316
],
"mean_audio_rtf": [
0.35
0.2717
]
}
},
@@ -194,18 +194,18 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
100,
300,
500,
800,
1500
78.4278,
195.8037,
333.7318,
561.805,
1038.043
],
"mean_e2el_ms": [
5000,
8000,
10000,
14000,
20000
4288.3029,
7429.9429,
9451.6184,
12607.5292,
18153.2547
]
}
},
@@ -239,10 +239,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
500
275.3875
],
"mean_e2el_ms": [
1000
730.6995
]
}
},
@@ -278,10 +278,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
200
166.2201
],
"mean_e2el_ms": [
800
673.5789
]
}
},
@@ -319,10 +319,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
600
357.7781
],
"mean_e2el_ms": [
1300
953.3493
]
}
}
@@ -32,25 +32,25 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
400,
300,
400,
600,
900
109.1218,
247.4411,
354.9384,
447.7885,
694.7487
],
"mean_e2el_ms": [
32000,
34000,
45000,
61000,
90000
22413.667,
30808.9715,
35677.844,
46345.8978,
68350.4853
],
"mean_audio_rtf": [
0.20,
0.25,
0.30,
0.45,
0.60
0.1695,
0.2033,
0.2467,
0.3234,
0.4438
]
}
},
@@ -79,13 +79,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
500
323.3573
],
"mean_e2el_ms": [
6000
4623.4173
],
"mean_audio_rtf": [
0.20
0.1406
]
}
},
@@ -116,13 +116,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
300
215.4502
],
"mean_e2el_ms": [
7000
4924.149
],
"mean_audio_rtf": [
0.20
0.1637
]
}
},
@@ -155,13 +155,13 @@
"percentile-metrics": "ttft,tpot,itl,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_ttft_ms": [
700
442.5815
],
"mean_e2el_ms": [
9000
6441.0986
],
"mean_audio_rtf": [
0.30
0.2091
]
}
}
@@ -40,18 +40,18 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
300,
300,
1000,
600,
800
69.1276,
255.1882,
310.639,
422.0337,
532.0582
],
"mean_e2el_ms": [
6000,
8000,
11000,
13000,
17000
4382.7211,
7510.3815,
9440.4854,
12553.0186,
16932.6065
]
}
},
@@ -86,10 +86,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
30
26.3381
],
"mean_e2el_ms": [
540
485.2436
]
}
},
@@ -126,10 +126,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
30
26.4517
],
"mean_e2el_ms": [
600
517.2017
]
}
},
@@ -168,10 +168,10 @@
"percentile-metrics": "ttft,tpot,itl,e2el",
"baseline": {
"mean_ttft_ms": [
40
27.6972
],
"mean_e2el_ms": [
700
561.3684
]
}
}
@@ -22,10 +22,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
"throughput_qps": 0.0721,
"latency_mean": 13.8398,
"peak_memory_mb_max": 54731.4,
"peak_memory_mb_mean": 54731.4
}
},
{
@@ -40,10 +40,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.01,
"latency_mean": 60,
"peak_memory_mb_max": 67000,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.0186,
"latency_mean": 53.6942,
"peak_memory_mb_max": 64402.4,
"peak_memory_mb_mean": 64402.4
}
}
]
@@ -75,10 +75,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"latency_mean": 20,
"peak_memory_mb_max": 55000,
"peak_memory_mb_mean": 55000
"throughput_qps": 0.0538,
"latency_mean": 18.5591,
"peak_memory_mb_max": 53485.0,
"peak_memory_mb_mean": 53485.0
}
}
]
@@ -120,10 +120,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.21,
"latency_mean": 10,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
"throughput_qps": 0.2776,
"latency_mean": 3.5938,
"peak_memory_mb_max": 55214.0,
"peak_memory_mb_mean": 55214.0
}
},
{
@@ -138,10 +138,10 @@
"num-input-images": 2,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.08,
"latency_mean": 20,
"peak_memory_mb_max": 66000,
"peak_memory_mb_mean": 66000
"throughput_qps": 0.1051,
"latency_mean": 9.4918,
"peak_memory_mb_max": 64265.6,
"peak_memory_mb_mean": 64265.6
}
}
]
@@ -21,10 +21,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 10,
"peak_memory_mb_max": 57000,
"peak_memory_mb_mean": 57000
"throughput_qps": 0.1129,
"latency_mean": 8.8352,
"peak_memory_mb_max": 55356.5,
"peak_memory_mb_mean": 55356.5
}
},
{
@@ -38,10 +38,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.02,
"latency_mean": 50,
"peak_memory_mb_max": 65000,
"peak_memory_mb_mean": 65000
"throughput_qps": 0.0240,
"latency_mean": 41.5686,
"peak_memory_mb_max": 62762.7,
"peak_memory_mb_mean": 62762.7
}
}
]
@@ -72,10 +72,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_max": 56000,
"peak_memory_mb_mean": 56000
"throughput_qps": 0.0672,
"latency_mean": 14.8517,
"peak_memory_mb_max": 53931.5,
"peak_memory_mb_mean": 53931.5
}
}
]
@@ -116,10 +116,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.26,
"latency_mean": 10,
"peak_memory_mb_max": 58000,
"peak_memory_mb_mean": 58000
"throughput_qps": 0.2879,
"latency_mean": 3.4701,
"peak_memory_mb_max": 55525.6,
"peak_memory_mb_mean": 55525.6
}
},
{
@@ -133,10 +133,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 10,
"peak_memory_mb_max": 67000,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.1113,
"latency_mean": 8.9656,
"peak_memory_mb_max": 63758.3,
"peak_memory_mb_mean": 63758.3
}
}
]
@@ -21,10 +21,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.05,
"latency_mean": 20,
"peak_memory_mb_max": 63000,
"peak_memory_mb_mean": 63000
"throughput_qps": 0.0703,
"latency_mean": 14.208,
"peak_memory_mb_max": 60543.5,
"peak_memory_mb_mean": 60543.5
}
},
{
@@ -38,10 +38,10 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.03,
"latency_mean": 30,
"peak_memory_mb_max": 63000,
"peak_memory_mb_mean": 63000
"throughput_qps": 0.0417,
"latency_mean": 23.914,
"peak_memory_mb_max": 60543.5,
"peak_memory_mb_mean": 60543.5
}
}
]
@@ -21,9 +21,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.35,
"latency_mean": 3,
"peak_memory_mb_mean": 56000
"throughput_qps": 0.4153,
"latency_mean": 2.4065,
"peak_memory_mb_mean": 52289.9
}
},
{
@@ -37,9 +37,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.03,
"latency_mean": 26,
"peak_memory_mb_mean": 65000
"throughput_qps": 0.0423,
"latency_mean": 23.5991,
"peak_memory_mb_mean": 61360.5
}
}
]
@@ -67,8 +67,8 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.35,
"latency_mean": 3,
"throughput_qps": 0.4175,
"latency_mean": 2.3953,
"peak_memory_mb_mean": 56000
}
},
@@ -83,8 +83,8 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.03,
"latency_mean": 26,
"throughput_qps": 0.0423,
"latency_mean": 23.5768,
"peak_memory_mb_mean": 65000
}
}
@@ -116,9 +116,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.11,
"latency_mean": 9,
"peak_memory_mb_mean": 55000
"throughput_qps": 0.1214,
"latency_mean": 8.2211,
"peak_memory_mb_mean": 51704.7
}
}
]
@@ -159,9 +159,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.46,
"latency_mean": 3,
"peak_memory_mb_mean": 56000
"throughput_qps": 0.559,
"latency_mean": 1.794,
"peak_memory_mb_mean": 52397.6775
}
},
{
@@ -175,9 +175,9 @@
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.18,
"latency_mean": 6,
"peak_memory_mb_mean": 67000
"throughput_qps": 0.1918,
"latency_mean": 5.2022,
"peak_memory_mb_mean": 61468.8
}
}
]
@@ -209,8 +209,8 @@
"warmup-num-inference-steps": 20,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": [0.30, 0.50, 0.60, 0.70],
"latency_mean": [3.50, 4.00, 7.00, 12.00],
"throughput_qps": [0.4125, 0.6714, 0.6674, 0.8159],
"latency_mean": [2.4215, 2.992, 5.9293, 9.656],
"peak_memory_mb_mean": [67000, 67000, 67000, 67000]
}
}
+90 -56
View File
@@ -27,10 +27,10 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3000
139.9434
],
"median_audio_rtf": [
0.6
0.1683
]
}
},
@@ -55,19 +55,19 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
1000,
3000,
14000
227.5653,
647.1272,
4777.2714
],
"median_audio_rtf": [
0.70,
1.15,
3.90
0.3313,
0.3642,
1.2282
],
"audio_throughput": [
12,
14,
14
23.501,
40.634,
43.0758
]
}
},
@@ -129,10 +129,10 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
2000
49.7908
],
"median_audio_rtf": [
0.40
0.1425
]
}
},
@@ -162,19 +162,19 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
300,
1000,
5000
75.3341,
713.7727,
5941.7179
],
"median_audio_rtf": [
0.25,
0.35,
1.10
0.1859,
0.3148,
1.1662
],
"audio_throughput": [
30,
40,
40
36.465,
49.6268,
48.3091
]
}
},
@@ -228,13 +228,13 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
100
56.6977
],
"median_audio_rtf": [
0.20
0.1555
],
"audio_throughput": [
10
11.5292
]
}
},
@@ -259,10 +259,10 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
100
52.0447
],
"median_audio_rtf": [
0.15
0.1452
]
}
},
@@ -291,19 +291,19 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
100,
1000,
5000
75.5967,
587.5877,
4960.3726
],
"median_audio_rtf": [
0.25,
0.40,
1.20
0.1871,
0.3143,
1.1703
],
"audio_throughput": [
30,
40,
40
40.2198,
48.9046,
48.0505
]
}
},
@@ -328,13 +328,13 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
100
56.3961
],
"median_audio_rtf": [
0.20
0.1542
],
"audio_throughput": [
9
9.6672
]
}
}
@@ -353,14 +353,24 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [20],
"max_concurrency": [1],
"num_prompts": [
20
],
"max_concurrency": [
1
],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"extra_body": {
"voice": "casual_male"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [150],
"median_audio_rtf": [0.15]
"median_audio_ttfp_ms": [
150
],
"median_audio_rtf": [
0.15
]
}
},
{
@@ -370,15 +380,27 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [80],
"max_concurrency": [8],
"num_prompts": [
80
],
"max_concurrency": [
8
],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"extra_body": {
"voice": "casual_male"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [1500],
"median_audio_rtf": [0.30],
"audio_throughput": [30.0]
"median_audio_ttfp_ms": [
1500
],
"median_audio_rtf": [
0.3
],
"audio_throughput": [
30.0
]
}
},
{
@@ -388,15 +410,27 @@
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"dataset_path": "benchmarks/build_dataset/seed_tts_smoke",
"num_prompts": [100],
"request_rate": [2.0],
"num_prompts": [
100
],
"request_rate": [
2.0
],
"seed_tts_locale": "en",
"extra_body": {"voice": "casual_male"},
"extra_body": {
"voice": "casual_male"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [3000],
"median_audio_rtf": [0.25],
"audio_throughput": [4.0]
"median_audio_ttfp_ms": [
3000
],
"median_audio_rtf": [
0.25
],
"audio_throughput": [
4.0
]
}
}
]
+13 -13
View File
@@ -23,10 +23,10 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
600
130.2115
],
"median_audio_rtf": [
0.2
0.0488
]
}
},
@@ -47,13 +47,13 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3500
475.3357
],
"median_audio_rtf": [
0.85
0.1671
],
"audio_throughput": [
8
22.8717
]
}
},
@@ -100,10 +100,10 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
350
75.7427
],
"median_audio_rtf": [
0.15
0.0419
]
}
},
@@ -127,13 +127,13 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
3000
351.5855
],
"median_audio_rtf": [
0.5
0.1207
],
"audio_throughput": [
10
73.7632
]
}
},
@@ -157,13 +157,13 @@
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"median_audio_ttfp_ms": [
500
80.1095
],
"median_audio_rtf": [
0.3
0.0485
],
"audio_throughput": [
5
18.3715
]
}
},
@@ -30,9 +30,9 @@
}
],
"baseline": {
"throughput_qps": 0.04,
"latency_mean": 30,
"peak_memory_mb_mean": 75000
"throughput_qps": 0.0465,
"latency_mean": 21.4354,
"peak_memory_mb_mean": 72557.58
}
}
]
@@ -72,9 +72,9 @@
}
],
"baseline": {
"throughput_qps": 0.06,
"latency_mean": 20,
"peak_memory_mb_mean": 48000
"throughput_qps": 0.0642,
"latency_mean": 15.5589,
"peak_memory_mb_mean": 45742.88
}
},
{
@@ -97,9 +97,9 @@
}
],
"baseline": {
"throughput_qps": 0.01,
"latency_mean": 90,
"peak_memory_mb_mean": 56000
"throughput_qps": 0.0128,
"latency_mean": 77.8565,
"peak_memory_mb_mean": 53947.84
}
}
]
+79 -36
View File
@@ -13,10 +13,12 @@ This module tests the cache backend implementations:
from unittest.mock import Mock, patch
import cache_dit
import pytest
from cache_dit import ForwardPattern
from vllm_omni.diffusion.cache.cache_dit_backend import (
CUSTOM_DIT_ENABLERS,
CacheDiTAdapterConfig,
CacheDiTBackend,
)
from vllm_omni.diffusion.cache.magcache.backend import MagCacheBackend
@@ -45,13 +47,20 @@ class TestCacheDiTBackend:
assert backend.config.Fn_compute_blocks == 4
assert backend.enabled is False
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_enable_single_transformer(self, mock_cache_dit):
def test_enable_single_transformer(self, mock_cache_dit, mock_block_adapter):
"""Test enabling cache-dit on single-transformer pipeline."""
# Mock pipeline
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DiTPipeline"
mock_transformer = Mock()
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"layers": ForwardPattern.Pattern_0,
},
)
mock_pipeline.transformer = mock_transformer
# Mock cache_dit functions
@@ -66,14 +75,20 @@ class TestCacheDiTBackend:
assert backend._refresh_func is not None
mock_cache_dit.enable_cache.assert_called_once()
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_refresh(self, mock_cache_dit):
def test_refresh(self, mock_cache_dit, mock_block_adapter):
"""Test refreshing cache context with SCM mask policy updates when num_inference_steps changes."""
# Mock pipeline
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DiTPipeline"
mock_transformer = Mock()
mock_pipeline.transformer = mock_transformer
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"layers": ForwardPattern.Pattern_0,
},
)
# Mock cache_dit functions
mock_cache_dit.enable_cache = Mock()
@@ -124,23 +139,24 @@ class TestCacheDiTBackend:
assert "cache_config" in call_args[1]
assert backend._last_num_inference_steps == 100
def test_hunyuan_custom_enabler_registered(self):
"""Test HunyuanImage3 custom cache-dit enabler is registered."""
assert "HunyuanImage3Pipeline" in CUSTOM_DIT_ENABLERS
def test_dreamid_custom_enabler_registered(self):
"""Test DreamIDOmni custom cache-dit enabler is registered."""
assert "DreamIDOmniPipeline" in CUSTOM_DIT_ENABLERS
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_enable_hunyuan_pipeline_uses_model_transformer(self, mock_cache_dit, mock_block_adapter):
"""Test HunyuanImage3 custom enabler uses pipeline.model for cache enable/refresh."""
"""Test HunyuanImage3 uses pipeline.transformer for cache enable/refresh.
NOTE: HunyuanImage3 no longer has a custom enabler, so this tests against the generic path.
"""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
mock_pipeline.model = Mock()
mock_pipeline.model.layers = Mock()
mock_pipeline.model._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"layers": ForwardPattern.Pattern_4,
},
)
# NOTE: pipe.transformer is pipe.model in HunyuanImage3Pipelines
mock_pipeline.transformer = mock_pipeline.model
mock_cache_dit.enable_cache = Mock()
mock_cache_dit.refresh_context = Mock()
@@ -152,9 +168,9 @@ class TestCacheDiTBackend:
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.model
assert adapter_kwargs["blocks"] is mock_pipeline.model.layers
assert adapter_kwargs["forward_pattern"] == adapter_kwargs["forward_pattern"].__class__.Pattern_4
assert len(adapter_kwargs["params_modifiers"]) == 1
assert len(adapter_kwargs["blocks"]) == 1
assert adapter_kwargs["blocks"][0] == mock_pipeline.model.layers
assert adapter_kwargs["forward_pattern"][0] == ForwardPattern.Pattern_4
mock_cache_dit.enable_cache.assert_called_once()
backend.refresh(mock_pipeline, num_inference_steps=12)
@@ -163,24 +179,23 @@ class TestCacheDiTBackend:
assert call_args[0][0] is mock_pipeline.model
assert call_args[1]["num_inference_steps"] == 12
def test_enable_hunyuan_pipeline_requires_model_layers(self):
"""Test HunyuanImage3 enabler fails with a formatted pipeline class name."""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "HunyuanImage3Pipeline"
mock_pipeline.model = Mock(spec=[])
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
with pytest.raises(ValueError, match="HunyuanImage3Pipeline"):
backend.enable(mock_pipeline)
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_enable_dreamid_pipeline_uses_fused_blocks(self, mock_cache_dit, mock_block_adapter):
"""Test DreamID uses pipeline.transformer for cache enable/refresh.
NOTE: DreamID no longer has a custom enabler, so this tests against the generic path.
"""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DreamIDOmniPipeline"
mock_pipeline.transformer = Mock()
mock_pipeline.transformer.fused_blocks = Mock()
mock_pipeline.transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"fused_blocks": ForwardPattern.Pattern_0,
},
has_separate_cfg=True,
)
mock_cache_dit.enable_cache = Mock()
mock_cache_dit.refresh_context = Mock()
@@ -193,10 +208,10 @@ class TestCacheDiTBackend:
mock_block_adapter.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["transformer"] is mock_pipeline.transformer
assert adapter_kwargs["blocks"] is mock_pipeline.transformer.fused_blocks
assert adapter_kwargs["forward_pattern"] == adapter_kwargs["forward_pattern"].__class__.Pattern_0
assert len(adapter_kwargs["blocks"]) == 1
assert adapter_kwargs["blocks"][0] == mock_pipeline.transformer.fused_blocks
assert adapter_kwargs["forward_pattern"][0] == ForwardPattern.Pattern_0
assert adapter_kwargs["has_separate_cfg"] is True
assert len(adapter_kwargs["params_modifiers"]) == 1
mock_cache_dit.enable_cache.assert_called_once()
backend.refresh(mock_pipeline, num_inference_steps=12)
@@ -205,15 +220,43 @@ class TestCacheDiTBackend:
assert call_args[0][0] is mock_pipeline.transformer
assert call_args[1]["num_inference_steps"] == 12
def test_enable_dreamid_pipeline_requires_fused_blocks(self):
@pytest.mark.parametrize("num_inference_steps", [1, 7])
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_refresh_scm_bypassed_for_unsupported_step_counts(
self, mock_cache_dit, mock_block_adapter, num_inference_steps
):
"""Ensure SCM is bypassed when num_inference_steps < 8 and not in (4, 6),
because cache_dit.steps_mask() raises for unsupported step count.
For these cases, we fall back to the non-SCM path to avoid crashing.
"""
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DreamIDOmniPipeline"
mock_pipeline.transformer = Mock(spec=[])
mock_pipeline.__class__.__name__ = "DiTPipeline"
mock_transformer = Mock()
mock_pipeline.transformer = mock_transformer
mock_transformer._cache_dit_adapter_config = CacheDiTAdapterConfig(
block_forward_patterns={
"layers": ForwardPattern.Pattern_0,
},
)
backend = CacheDiTBackend({"Fn_compute_blocks": 2})
mock_cache_dit.enable_cache = Mock()
mock_cache_dit.refresh_context = Mock()
mock_cache_dit.steps_mask = cache_dit.steps_mask
with pytest.raises(ValueError, match="DreamIDOmniPipeline"):
backend.enable(mock_pipeline)
# Create a cache config with an scm policy & enable it
config = DiffusionCacheConfig(scm_steps_mask_policy="fast")
backend = CacheDiTBackend(config)
backend.enable(mock_pipeline)
backend.refresh(mock_pipeline, num_inference_steps=num_inference_steps)
mock_cache_dit.refresh_context.assert_called_once()
call_args = mock_cache_dit.refresh_context.call_args
# Ensure that we properly guard, i.e., cache config is filtered
assert call_args[0][0] == mock_transformer
assert call_args[1]["num_inference_steps"] == num_inference_steps
assert "cache_config" not in call_args[1]
class TestTeaCacheBackend:
+31 -16
View File
@@ -5,6 +5,7 @@
Model specific tests for CacheDiT enablement.
"""
import sys
from unittest.mock import Mock, patch
import pytest
@@ -12,36 +13,49 @@ import torch
from cache_dit.caching.cache_blocks.pattern_0_1_2 import CachedBlocks_Pattern_0_1_2
import vllm_omni.diffusion.cache.cache_dit_backend as cd_backend
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTBackend
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig, CacheDiTBackend
from vllm_omni.diffusion.data import DiffusionCacheConfig
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer
from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel
from vllm_omni.diffusion.models.longcat_image.longcat_image_transformer import LongCatImageTransformer2DModel
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
from vllm_omni.platforms import current_omni_platform
# NOTE: We patch DreamID Omni's modules here with mocks so that we can import and inspect
# the class even though the dependency may not be set up correctly; this is ok for these
# tests because we just inspect it and never initialize the model.
for mod in ("dreamid_omni", "dreamid_omni.modules", "dreamid_omni.modules.model"):
sys.modules.setdefault(mod, Mock())
# isort: split
from vllm_omni.diffusion.models.dreamid_omni.fusion import FusionModel as DreamIdOmniModel # noqa: E402
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SEPARATE_CFG_ENABLERS = [
cd_backend.enable_cache_for_dreamid_omni,
cd_backend.enable_cache_for_ltx2,
cd_backend.enable_cache_for_helios,
cd_backend.enable_cache_for_wan22,
cd_backend.enable_cache_for_longcat_image,
cd_backend.enable_cache_for_cosmos3,
SEPARATE_CFG_TRANSFORMERS = [
DreamIdOmniModel,
LTX2VideoTransformer3DModel,
HeliosTransformer3DModel,
LongCatImageTransformer2DModel,
Cosmos3VFMTransformer,
]
SAMPLE_CACHE_CONFIG = DiffusionCacheConfig()
@pytest.mark.parametrize("enabler", SEPARATE_CFG_ENABLERS)
@pytest.mark.parametrize("transformer_model", SEPARATE_CFG_TRANSFORMERS)
def test_cache_dit_configs_have_separate_cfg(transformer_model):
"""Check models with separate CFG set has_separate_cfg=True in their configs."""
assert hasattr(transformer_model, "_cache_dit_adapter_config")
assert isinstance(transformer_model._cache_dit_adapter_config, CacheDiTAdapterConfig)
assert transformer_model._cache_dit_adapter_config.has_separate_cfg is True
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_separate_cfg(mock_cache_dit, mock_block_adapter, enabler):
"""Ensure that custom enablers for models with separate CFG pass
the param through to cache_dit correctly.
Regression test for: https://github.com/vllm-project/vllm-omni/pull/2860
"""
def test_separate_wan22_custom_enabler_has_separate_cfg(mock_cache_dit, mock_block_adapter):
"""Ensure that Wan22, which has a custom enabler, setts custom CFG correctly."""
mock_pipeline = Mock()
enabler(mock_pipeline, SAMPLE_CACHE_CONFIG)
cd_backend.enable_cache_for_wan22(mock_pipeline, SAMPLE_CACHE_CONFIG)
mock_cache_dit.enable_cache.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
@@ -55,6 +69,7 @@ def test_cosmos3_cache_dit_wraps_gen_layers(mock_cache_dit, mock_block_adapter):
mock_pipeline = Mock()
gen_layers = object()
mock_pipeline.transformer.gen_layers = gen_layers
mock_pipeline.transformer._cache_dit_adapter_config = Cosmos3VFMTransformer._cache_dit_adapter_config
cd_backend.enable_cache_for_cosmos3(mock_pipeline, SAMPLE_CACHE_CONFIG)
+116
View File
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for StepCacheBackend and StepCacheState."""
from unittest.mock import Mock
import pytest
import torch
from vllm_omni.diffusion.cache.selector import get_cache_backend
from vllm_omni.diffusion.cache.stepcache import (
STEP_CACHE_CONFIG_ATTR,
STEP_CACHE_STATE_ATTR,
StepCacheBackend,
StepCacheConfig,
StepCacheState,
is_stepcache_active,
)
from vllm_omni.diffusion.data import DiffusionCacheConfig
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_prev_predictions(sim: float, dim: int = 8) -> list[tuple[torch.Tensor]]:
v_prev = torch.randn(1, dim)
if sim >= 0.99:
v_last = v_prev.clone()
else:
v_last = torch.randn(1, dim)
return [(v_prev,), (v_last,)]
class TestStepCacheConfig:
def test_from_diffusion_cache_config_respects_step_cache_dit_enabled(self):
enabled = StepCacheConfig.from_diffusion_cache_config(DiffusionCacheConfig(step_cache_dit_enabled=True))
disabled = StepCacheConfig.from_diffusion_cache_config(DiffusionCacheConfig(step_cache_dit_enabled=False))
assert enabled.enabled is True
assert disabled.enabled is False
class TestStepCacheState:
def test_disabled_always_runs(self):
state = StepCacheState(StepCacheConfig(enabled=False))
assert state.should_run_step(_make_prev_predictions(0.99)) is True
def test_insufficient_history_always_runs(self):
state = StepCacheState(StepCacheConfig(min_history_steps=2))
assert state.should_run_step([(torch.randn(1, 4),)]) is True
def test_high_similarity_skips_with_countdown(self):
state = StepCacheState(
StepCacheConfig(
sim_thresholds=(0.95,),
skip_countdowns=(2,),
)
)
preds = _make_prev_predictions(0.99)
assert state.should_run_step(preds) is False
assert state.skip_countdown == 2
assert state.should_run_step(preds) is False
assert state.should_run_step(preds) is True
def test_low_similarity_runs(self):
state = StepCacheState(StepCacheConfig())
preds = _make_prev_predictions(0.5)
assert state.should_run_step(preds) is True
def test_trim_history(self):
state = StepCacheState(StepCacheConfig(max_history=2))
history: list[tuple[torch.Tensor]] = [(torch.randn(1, 2),) for _ in range(4)]
state.trim_history(history)
assert len(history) == 2
class TestStepCacheBackend:
def test_enable_dreamzero_pipeline(self):
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DreamZeroPipeline"
backend = StepCacheBackend(DiffusionCacheConfig())
backend.enable(mock_pipeline)
assert backend.enabled is True
cfg = getattr(mock_pipeline, STEP_CACHE_CONFIG_ATTR)
assert isinstance(cfg, StepCacheConfig)
assert is_stepcache_active(mock_pipeline)
state = getattr(mock_pipeline, STEP_CACHE_STATE_ATTR)
assert isinstance(state, StepCacheState)
def test_refresh_resets_state(self):
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "DreamZeroPipeline"
backend = StepCacheBackend(DiffusionCacheConfig())
backend.enable(mock_pipeline)
state = getattr(mock_pipeline, STEP_CACHE_STATE_ATTR)
state.skip_countdown = 3
backend.refresh(mock_pipeline, num_inference_steps=16, verbose=False)
assert state.skip_countdown == 0
def test_enable_unsupported_pipeline_raises(self):
mock_pipeline = Mock()
mock_pipeline.__class__.__name__ = "QwenImagePipeline"
backend = StepCacheBackend(DiffusionCacheConfig())
with pytest.raises(ValueError, match="step_cache backend does not support"):
backend.enable(mock_pipeline)
def test_selector_returns_backend(self):
backend = get_cache_backend("step_cache", {"velocity_sim_thresholds": [0.9]})
assert isinstance(backend, StepCacheBackend)
assert backend.config.velocity_sim_thresholds == [0.9]
def test_legacy_backend_aliases(self):
assert isinstance(get_cache_backend("stepcache", {}), StepCacheBackend)
assert isinstance(get_cache_backend("step_cache_dit", {}), StepCacheBackend)
@@ -0,0 +1,689 @@
# ruff: noqa: N803, E741
"""Layer-level correctness & performance test for MoT parallel linear layers.
Compares two equivalent computation paths:
- Reference: 2x standard vLLM parallel linear layers + PyTorch index
scatter/gather (text_linear(x[text_idx]) + vae_linear(x[vae_idx]))
- Target: 1x MoT fused parallel linear layer
(mot_linear(x, text_indices, vae_indices))
The reference path uses cuBLAS GEMM (always auto-tuned by cuBLAS).
The MoT path uses a fused Triton kernel whose tile config is loaded
from a JSON file matched by ``device + dtype``. If no tuned
config is found for the current GPU, the kernel falls back to a conservative
default and a warning is printed — the correctness test still passes
but the performance comparison is NOT representative.
Usage::
pytest tests/diffusion/kernels/mot/test_mot_linear.py -v -s
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
import pytest
import torch
from vllm.config import VllmConfig, set_current_vllm_config
from vllm.model_executor.layers.linear import (
QKVParallelLinear,
RowParallelLinear,
)
from vllm_omni.diffusion.distributed.parallel_state import (
destroy_distributed_env,
init_distributed_environment,
initialize_model_parallel,
model_parallel_is_initialized,
)
from vllm_omni.diffusion.layers.mot.mot_qkv_parallel_linear import (
MoTQKVParallelLinear,
)
from vllm_omni.diffusion.layers.mot.mot_row_parallel_linear import (
MoTRowParallelLinear,
)
from vllm_omni.diffusion.layers.mot.ops.mot_gemm import (
get_best_mot_config,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# BAGEL-7B-MoT architecture parameters
_BAGEL_HEAD_SIZE = 128
_BAGEL_TOTAL_NUM_HEADS = 28
_BAGEL_TOTAL_NUM_KV_HEADS = 4
_VAE_CHUNK_SIZE = 1024 # the token number of one image
_IMAGE_NUM = [1, 2, 4, 8]
@pytest.fixture(scope="module", autouse=True)
def _init_single_rank_tp_env():
"""Initialize single-rank distributed/TP env for vLLM linear params."""
os.environ.setdefault("RANK", "0")
os.environ.setdefault("LOCAL_RANK", "0")
os.environ.setdefault("WORLD_SIZE", "1")
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
os.environ.setdefault("MASTER_PORT", "29501")
if not torch.distributed.is_initialized():
init_distributed_environment(world_size=1, rank=0, local_rank=0)
if not model_parallel_is_initialized():
initialize_model_parallel(
data_parallel_size=1,
cfg_parallel_size=1,
sequence_parallel_size=1,
ulysses_degree=1,
ring_degree=1,
tensor_parallel_size=1,
pipeline_parallel_size=1,
)
yield
destroy_distributed_env()
# ---------------------------------------------------------------------------
# DType configuration — extensible for future quantized types
# ---------------------------------------------------------------------------
@dataclass
class DTypeConfig:
"""Parsed dtype configuration for a test case."""
torch_dtype: torch.dtype
use_fp8_w8a8: bool = False
use_int8_w8a16: bool = False
use_int4_w4a16: bool = False
def _parse_dtype(dtype_str: str) -> DTypeConfig:
"""Parse a dtype string into quantization flags and torch dtype.
Supported now:
"w16a16_bf16" — BF16 weights & activations (no quantization)
"w16a16_fp16" — FP16 weights & activations (no quantization)
Reserved for future:
"fp8_w8a8" — FP8 W8A8 quantization
"int8_w8a16" — INT8 weight-only quantization
"int4_w4a16" — INT4 weight-only quantization
"""
supported: dict[str, DTypeConfig] = {
"w16a16_bf16": DTypeConfig(torch_dtype=torch.bfloat16),
"w16a16_fp16": DTypeConfig(torch_dtype=torch.float16),
}
if dtype_str in supported:
return supported[dtype_str]
pytest.skip(f"Quantized dtype '{dtype_str}' not yet implemented in layer test")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _report_mot_config(K: int, N: int, M: int):
"""Print which Triton tile config the MoT kernel will use."""
loaded_m_key, config = get_best_mot_config(M, N, K, None)
if loaded_m_key == -1:
print(
" [config] WARNING: No tuned config found — "
"using conservative default. "
"Performance numbers are NOT representative. "
"Run mot_linear_benchmarks.py --tune to generate configs."
)
else:
print(f" [config] Tuned config loaded (actual M={M}, loaded M={loaded_m_key}) config = {config})")
def _make_indices(image_num: int, vae_chunk_size: int, device: str = "cuda") -> tuple[torch.Tensor, torch.Tensor, int]:
"""
Simulate exact Bagel-MoT distributions for image generation:
Pattern per image like: [1 Text] + [4096 VAE] + [1 Text]
Returns text_indices, vae_indices, and the exact total M.
"""
text_idx_list = []
vae_idx_list = []
current_idx = 0
for _ in range(image_num):
text_idx_list.append(current_idx)
current_idx += 1
vae_idx_list.extend(range(current_idx, current_idx + vae_chunk_size))
current_idx += vae_chunk_size
text_idx_list.append(current_idx)
current_idx += 1
text_indices = torch.tensor(text_idx_list, dtype=torch.long, device=device)
vae_indices = torch.tensor(vae_idx_list, dtype=torch.long, device=device)
exact_M = current_idx # exact_M = image_num * (vae_chunk_size + 2)
return text_indices, vae_indices, exact_M
def _benchmark(fn, warmup: int = 20, iters: int = 100) -> float:
"""Return mean latency in milliseconds."""
cache_flusher = torch.empty(int(256 * 1024 * 1024 / 4), dtype=torch.int32, device="cuda")
for _ in range(warmup):
fn()
torch.accelerator.synchronize()
t_flush_start = time.perf_counter()
for _ in range(iters):
cache_flusher.zero_()
torch.accelerator.synchronize()
flush_time_total = time.perf_counter() - t_flush_start
# 3. Measure the total time of "flush cache + operator execution"
torch.accelerator.synchronize()
t_total_start = time.perf_counter()
for _ in range(iters):
cache_flusher.zero_()
fn()
torch.accelerator.synchronize()
total_time = time.perf_counter() - t_total_start
# 4. Asynchronous subtraction separation
# The E2E time of the pure operator = total time - flush time
pure_fn_time_total = total_time - flush_time_total
avg_ms = (pure_fn_time_total / iters) * 1000.0
del cache_flusher
return max(avg_ms, 0.001)
def _sync_weights(ref_text, ref_vae, mot_layer):
"""Assign the same random weights to reference layers and MoT layer."""
with torch.no_grad():
W_text = torch.randn_like(ref_text.weight) * 0.02
W_vae = torch.randn_like(ref_vae.weight) * 0.02
ref_text.weight.copy_(W_text)
ref_vae.weight.copy_(W_vae)
mot_layer.weight.copy_(W_text)
mot_layer.gen_exp.weight.copy_(W_vae)
if ref_text.bias is not None and mot_layer.bias is not None:
b_text = torch.randn_like(ref_text.bias) * 0.02
ref_text.bias.copy_(b_text)
mot_layer.bias.copy_(b_text)
if ref_vae.bias is not None and mot_layer.gen_exp.bias is not None:
b_vae = torch.randn_like(ref_vae.bias) * 0.02
ref_vae.bias.copy_(b_vae)
mot_layer.gen_exp.bias.copy_(b_vae)
def _reference_forward(x, text_indices, vae_indices, text_linear, vae_linear):
"""Reference path: index-gather → 2x standard linear → index-scatter."""
M = x.size(0)
out_text = text_linear(x[text_indices])
out_vae = vae_linear(x[vae_indices])
if isinstance(out_text, tuple):
out_text = out_text[0]
if isinstance(out_vae, tuple):
out_vae = out_vae[0]
N = out_text.size(-1)
output = torch.empty(M, N, dtype=x.dtype, device=x.device)
output[text_indices] = out_text
output[vae_indices] = out_vae
return output
def _check_and_report(ref: torch.Tensor, mot: torch.Tensor, tag: str):
"""Compare outputs, print metrics, and assert correctness.
Both ``ref`` and ``mot`` are in the original compute dtype (e.g. bf16).
We upcast to fp32 solely for computing error metrics with higher
arithmetic precision — the actual layer outputs remain bf16.
"""
# Upcast for numerically stable error computation only
ref_hp = ref.float()
mot_hp = mot.float()
abs_err = (ref_hp - mot_hp).abs()
max_abs = abs_err.max().item()
# Mixed metric: relative error where |ref| >= 1, absolute error otherwise
denom = ref_hp.abs().clamp(min=1.0)
max_rel = (abs_err / denom).max().item()
cos_sim = (
torch.nn.functional.cosine_similarity(
ref_hp,
mot_hp,
dim=-1,
)
.min()
.item()
)
print(f"\n [{tag}] max_abs={max_abs:.4e} max_rel={max_rel:.4e} min_cos_sim={cos_sim:.6f}")
# Cosine similarity is the primary correctness gate: robust to scale
# and accumulation-order differences between cuBLAS and Triton.
# For bf16 GEMM with K up to ~20k, cos_sim > 0.99 is easily achieved.
assert cos_sim > 0.98, f"Cosine similarity too low: {cos_sim:.6f}"
# Supplementary per-element check (generous to avoid flaky failures
# on extreme K dimensions like 18944)
assert max_rel < 0.1, f"Max relative error too large: {max_rel:.4e}"
def _run_timing(
ref_fn,
mot_fn,
tag: str,
warmup: int = 20,
iters: int = 100,
):
"""Benchmark both paths and print timing comparison."""
ref_ms = _benchmark(ref_fn, warmup=warmup, iters=iters)
mot_ms = _benchmark(mot_fn, warmup=warmup, iters=iters)
speedup = ref_ms / mot_ms if mot_ms > 0 else float("inf")
print(f" [{tag}] Ref(2x linear): {ref_ms:.3f} ms | MoT(fused): {mot_ms:.3f} ms | Speedup: {speedup:.2f}x")
# =========================================================================
# Test: qkv proj
# =========================================================================
@pytest.mark.parametrize("bias", [True, False])
@pytest.mark.parametrize(
"image_num, K, N, dtype",
[(num, 3584, 4608, "w16a16_bf16") for num in _IMAGE_NUM] + [(num, 3584, 4608, "w16a16_fp16") for num in _IMAGE_NUM],
ids=[f"img{num}_K3584_N4608_bf16" for num in _IMAGE_NUM] + [f"img{num}_K3584_N4608_fp16" for num in _IMAGE_NUM],
)
def test_mot_qkv_parallel(image_num: int, K: int, N: int, dtype: str, bias: bool):
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
with set_current_vllm_config(VllmConfig()):
text_linear = QKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=bias,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
vae_linear = QKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=bias,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTQKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=bias,
vae_bias=bias,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
assert text_linear.output_size_per_partition == N, (
f"Expected output_size_per_partition={N}, "
f"got {text_linear.output_size_per_partition}. "
f"Check head parameters."
)
_sync_weights(text_linear, vae_linear, mot_linear)
text_idx, vae_idx, M = _make_indices(image_num, _VAE_CHUNK_SIZE)
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
tag = f"QKVParallel M={M} K={K} N={N}"
_report_mot_config(K, N, M)
with torch.no_grad():
ref = _reference_forward(
x,
text_idx,
vae_idx,
text_linear,
vae_linear,
)
mot_out, _ = mot_linear(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, tag)
with torch.no_grad():
_run_timing(
lambda: _reference_forward(
x,
text_idx,
vae_idx,
text_linear,
vae_linear,
),
lambda: mot_linear(x, text_idx, vae_idx),
tag,
)
# =========================================================================
# Test: o proj
# =========================================================================
@pytest.mark.parametrize("bias", [True, False])
@pytest.mark.parametrize(
"image_num, K, N, dtype",
[(num, 3584, 3584, "w16a16_bf16") for num in _IMAGE_NUM] + [(num, 3584, 3584, "w16a16_fp16") for num in _IMAGE_NUM],
ids=[f"img{num}_K3584_N3584_bf16" for num in _IMAGE_NUM] + [f"img{num}_K3584_N3584_fp16" for num in _IMAGE_NUM],
)
def test_mot_o_proj(
image_num: int,
K: int,
N: int,
dtype: str,
bias: bool,
):
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
with set_current_vllm_config(VllmConfig()):
text_linear = RowParallelLinear(
K,
N,
bias=bias,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
vae_linear = RowParallelLinear(
K,
N,
bias=bias,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTRowParallelLinear(
K,
N,
bias=bias,
vae_bias=bias,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
_sync_weights(text_linear, vae_linear, mot_linear)
text_idx, vae_idx, M = _make_indices(image_num, _VAE_CHUNK_SIZE)
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
tag = f"O Proj M={M} K={K} N={N}"
_report_mot_config(K, N, M)
# Correctness (also warms up Triton JIT compilation)
with torch.no_grad():
ref = _reference_forward(
x,
text_idx,
vae_idx,
text_linear,
vae_linear,
)
mot_out, _ = mot_linear(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, tag)
# Performance
with torch.no_grad():
_run_timing(
lambda: _reference_forward(
x,
text_idx,
vae_idx,
text_linear,
vae_linear,
),
lambda: mot_linear(x, text_idx, vae_idx),
tag,
)
# =========================================================================
# Test: und-mode (text_indices=None) — falls back to standard forward
# =========================================================================
@pytest.mark.parametrize("bias", [True, False])
@pytest.mark.parametrize(
"K, N, dtype",
[
(3584, 4608, "w16a16_bf16"),
(3584, 4608, "w16a16_fp16"),
],
ids=["QKV_K3584_N4608_bf16", "QKV_K3584_N4608_fp16"],
)
def test_mot_qkv_und_mode(K: int, N: int, dtype: str, bias: bool):
"""und-mode: text_indices=None should produce same output as standard QKVParallelLinear."""
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
M = 1026
with set_current_vllm_config(VllmConfig()):
ref_linear = QKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=bias,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTQKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=bias,
vae_bias=bias,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
with torch.no_grad():
mot_linear.weight.copy_(ref_linear.weight)
if bias and ref_linear.bias is not None:
mot_linear.bias.copy_(ref_linear.bias)
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
with torch.no_grad():
ref_out, _ = ref_linear(x)
mot_out, _ = mot_linear(x, text_indices=None, vae_indices=None)
_check_and_report(ref_out, mot_out, f"QKV und-mode M={M} K={K}")
@pytest.mark.parametrize("bias", [True, False])
@pytest.mark.parametrize(
"K, N, dtype",
[
(3584, 3584, "w16a16_bf16"),
(3584, 3584, "w16a16_fp16"),
],
ids=["Row_K3584_N3584_bf16", "Row_K3584_N3584_fp16"],
)
def test_mot_row_und_mode(K: int, N: int, dtype: str, bias: bool):
"""und-mode: text_indices=None should produce same output as standard RowParallelLinear."""
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
M = 1026
with set_current_vllm_config(VllmConfig()):
ref_linear = RowParallelLinear(
K,
N,
bias=bias,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTRowParallelLinear(
K,
N,
bias=bias,
vae_bias=bias,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
with torch.no_grad():
mot_linear.weight.copy_(ref_linear.weight)
if bias and ref_linear.bias is not None:
mot_linear.bias.copy_(ref_linear.bias)
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
with torch.no_grad():
ref_out, _ = ref_linear(x)
mot_out, _ = mot_linear(x, text_indices=None, vae_indices=None)
_check_and_report(ref_out, mot_out, f"Row und-mode M={M} K={K}")
# =========================================================================
# Test: boundary cases (all-text, all-VAE)
# =========================================================================
@pytest.mark.parametrize(
"boundary_mode",
["all_text", "all_vae"],
ids=["all_text", "all_vae"],
)
@pytest.mark.parametrize("dtype", ["w16a16_bf16", "w16a16_fp16"])
def test_mot_qkv_boundary(boundary_mode: str, dtype: str):
"""Boundary: all tokens routed to one expert."""
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
M = 512
K = 3584
with set_current_vllm_config(VllmConfig()):
text_linear = QKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=False,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
vae_linear = QKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=False,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTQKVParallelLinear(
hidden_size=K,
head_size=_BAGEL_HEAD_SIZE,
total_num_heads=_BAGEL_TOTAL_NUM_HEADS,
total_num_kv_heads=_BAGEL_TOTAL_NUM_KV_HEADS,
bias=False,
vae_bias=False,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
_sync_weights(text_linear, vae_linear, mot_linear)
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
if boundary_mode == "all_text":
text_idx, vae_idx = all_indices, empty_indices
else:
text_idx, vae_idx = empty_indices, all_indices
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
with torch.no_grad():
ref = _reference_forward(x, text_idx, vae_idx, text_linear, vae_linear)
mot_out, _ = mot_linear(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, f"QKV boundary={boundary_mode} M={M}")
@pytest.mark.parametrize(
"boundary_mode",
["all_text", "all_vae"],
ids=["all_text", "all_vae"],
)
@pytest.mark.parametrize("dtype", ["w16a16_bf16", "w16a16_fp16"])
def test_mot_row_boundary(boundary_mode: str, dtype: str):
"""Boundary: all tokens routed to one expert for RowParallel."""
dcfg = _parse_dtype(dtype)
torch.manual_seed(42)
M = 512
K = 3584
N = 3584
with set_current_vllm_config(VllmConfig()):
text_linear = RowParallelLinear(
K,
N,
bias=False,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
vae_linear = RowParallelLinear(
K,
N,
bias=False,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
mot_linear = MoTRowParallelLinear(
K,
N,
bias=False,
vae_bias=False,
input_is_parallel=True,
params_dtype=dcfg.torch_dtype,
disable_tp=True,
).cuda()
_sync_weights(text_linear, vae_linear, mot_linear)
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
if boundary_mode == "all_text":
text_idx, vae_idx = all_indices, empty_indices
else:
text_idx, vae_idx = empty_indices, all_indices
x = torch.randn(M, K, dtype=dcfg.torch_dtype, device="cuda")
with torch.no_grad():
ref = _reference_forward(x, text_idx, vae_idx, text_linear, vae_linear)
mot_out, _ = mot_linear(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, f"Row boundary={boundary_mode} M={M}")
@@ -0,0 +1,357 @@
# ruff: noqa: N803, E741
"""Layer-level correctness & performance test for MoTRMSNorm.
Compares two equivalent computation paths:
- Reference: 2x vLLM IR RMSNorm op + PyTorch index scatter/gather
(ir.ops.rms_norm(x[text_idx], text_w) + ir.ops.rms_norm(x[vae_idx], vae_w))
- Target: 1x MoTRMSNorm fused Triton kernel
(mot_norm(x, text_indices, vae_indices))
Usage::
pytest tests/diffusion/kernels/mot/test_mot_norm.py -v -s
"""
from __future__ import annotations
import time
import pytest
import torch
from vllm import ir
from vllm.config import VllmConfig, set_current_vllm_config
from vllm_omni.diffusion.layers.mot.mot_layernorm import MoTRMSNorm
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_EPS = 1e-6
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_indices(
M: int,
text_ratio: float,
device: str = "cuda",
) -> tuple[torch.Tensor, torch.Tensor]:
M_text = max(1, int(M * text_ratio))
perm = torch.randperm(M, device=device)
text_indices = perm[:M_text].sort().values
vae_indices = perm[M_text:].sort().values
return text_indices, vae_indices
def _benchmark(fn, warmup: int = 20, iters: int = 200) -> float:
"""Return mean latency in milliseconds."""
for _ in range(warmup):
fn()
torch.accelerator.synchronize()
t0 = time.perf_counter()
for _ in range(iters):
fn()
torch.accelerator.synchronize()
return (time.perf_counter() - t0) / iters * 1000.0
def _reference_forward(
x: torch.Tensor,
text_indices: torch.Tensor,
vae_indices: torch.Tensor,
text_weight: torch.Tensor,
vae_weight: torch.Tensor,
eps: float,
) -> torch.Tensor:
"""Reference: index-gather -> 2x vLLM IR RMSNorm op -> scatter."""
output = torch.empty_like(x)
output[text_indices] = ir.ops.rms_norm(x[text_indices], text_weight, eps)
output[vae_indices] = ir.ops.rms_norm(x[vae_indices], vae_weight, eps)
return output
def _reference_forward_head_norm(
x: torch.Tensor,
text_indices: torch.Tensor,
vae_indices: torch.Tensor,
text_weight: torch.Tensor,
vae_weight: torch.Tensor,
eps: float,
) -> torch.Tensor:
"""Reference for head_norm=True path.
The MoT routing happens on token dimension while RMSNorm is applied
independently on each head's last dimension.
"""
output = torch.empty_like(x)
hidden_size = x.shape[-1]
output[text_indices] = ir.ops.rms_norm(
x[text_indices].reshape(-1, hidden_size),
text_weight,
eps,
).reshape_as(x[text_indices])
output[vae_indices] = ir.ops.rms_norm(
x[vae_indices].reshape(-1, hidden_size),
vae_weight,
eps,
).reshape_as(x[vae_indices])
return output
def _check_and_report(ref: torch.Tensor, mot: torch.Tensor, tag: str):
"""Compare outputs, print metrics, and assert correctness.
Both ``ref`` and ``mot`` are in the original compute dtype (e.g. bf16).
We upcast to fp32 solely for computing error metrics with higher
arithmetic precision — the actual layer outputs remain bf16.
"""
ref_hp = ref.float()
mot_hp = mot.float()
abs_err = (ref_hp - mot_hp).abs()
max_abs = abs_err.max().item()
denom = ref_hp.abs().clamp(min=1.0)
max_rel = (abs_err / denom).max().item()
cos_sim = (
torch.nn.functional.cosine_similarity(
ref_hp,
mot_hp,
dim=-1,
)
.min()
.item()
)
print(f"\n [{tag}] max_abs={max_abs:.4e} max_rel={max_rel:.4e} min_cos_sim={cos_sim:.6f}")
# RMSNorm is element-wise (no cross-element accumulation like GEMM),
# so the error between two fp32-accumulating implementations should
# be very small — well within bf16 rounding.
assert cos_sim > 0.99, f"Cosine similarity too low: {cos_sim:.6f}"
assert max_rel < 0.05, f"Max relative error too large: {max_rel:.4e}"
def _run_timing(
ref_fn,
mot_fn,
tag: str,
warmup: int = 20,
iters: int = 200,
):
"""Benchmark both paths and print timing comparison."""
ref_ms = _benchmark(ref_fn, warmup=warmup, iters=iters)
mot_ms = _benchmark(mot_fn, warmup=warmup, iters=iters)
speedup = ref_ms / mot_ms if mot_ms > 0 else float("inf")
print(f" [{tag}] Ref(2x rms_norm): {ref_ms:.3f} ms | MoT(fused): {mot_ms:.3f} ms | Speedup: {speedup:.2f}x")
# =========================================================================
# Test: MoTRMSNorm vs 2x vLLM rms_norm
# =========================================================================
@pytest.mark.parametrize(
"M, hidden_size, text_ratio, dtype",
[
(2048, 3584, 0.01, torch.bfloat16),
(8192, 3584, 0.01, torch.bfloat16),
(2048, 128, 0.01, torch.bfloat16),
(8192, 128, 0.01, torch.bfloat16),
(2048, 3584, 0.01, torch.float16),
(8192, 3584, 0.01, torch.float16),
],
ids=[
"M2048_H3584_layernorm_bf16",
"M8192_H3584_layernorm_bf16",
"M2048_H128_qknorm_bf16",
"M8192_H128_qknorm_bf16",
"M2048_H3584_layernorm_fp16",
"M8192_H3584_layernorm_fp16",
],
)
def test_mot_rms_norm(M: int, hidden_size: int, text_ratio: float, dtype: torch.dtype):
torch.manual_seed(42)
# --- Build MoT layer ---
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
with torch.no_grad():
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
W_vae = torch.randn(hidden_size, dtype=dtype, device="cuda")
mot_norm.weight.data.copy_(W_text)
mot_norm.gen_weight.data.copy_(W_vae)
# --- Build reference weights (same data) ---
ref_text_weight = W_text.clone()
ref_vae_weight = W_vae.clone()
# --- Inputs ---
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
text_idx, vae_idx = _make_indices(M, text_ratio)
tag = f"RMSNorm M={M} H={hidden_size}"
# vLLM's IR RMSNorm op may inspect global config; wrap in VllmConfig
# context for safety.
with set_current_vllm_config(VllmConfig()):
# --- Correctness (also warms up Triton JIT) ---
with torch.no_grad():
ref = _reference_forward(
x,
text_idx,
vae_idx,
ref_text_weight,
ref_vae_weight,
_EPS,
)
mot_out = mot_norm(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, tag)
# --- Performance ---
with torch.no_grad():
_run_timing(
lambda: _reference_forward(
x,
text_idx,
vae_idx,
ref_text_weight,
ref_vae_weight,
_EPS,
),
lambda: mot_norm(x, text_idx, vae_idx),
tag,
)
@pytest.mark.parametrize(
"M, num_heads, head_dim, text_ratio",
[
(2048, 28, 128, 0.01),
],
ids=[
"M2048_NH28_HD128_qknorm_head_norm",
],
)
def test_mot_rms_norm_head_norm(
M: int,
num_heads: int,
head_dim: int,
text_ratio: float,
):
torch.manual_seed(42)
dtype = torch.bfloat16
# --- Build MoT layer (head_norm path) ---
mot_norm = MoTRMSNorm(head_dim, head_norm=True, eps=_EPS).cuda()
with torch.no_grad():
W_text = torch.randn(head_dim, dtype=dtype, device="cuda")
W_vae = torch.randn(head_dim, dtype=dtype, device="cuda")
mot_norm.weight.data.copy_(W_text)
mot_norm.gen_weight.data.copy_(W_vae)
# --- Build reference weights (same data) ---
ref_text_weight = W_text.clone()
ref_vae_weight = W_vae.clone()
# --- Inputs ---
x = torch.randn(M, num_heads, head_dim, dtype=dtype, device="cuda")
text_idx, vae_idx = _make_indices(M, text_ratio)
tag = f"RMSNorm(head_norm=True) M={M} NH={num_heads} HD={head_dim}"
with set_current_vllm_config(VllmConfig()):
# --- Correctness (also warms up Triton JIT) ---
with torch.no_grad():
ref = _reference_forward_head_norm(
x,
text_idx,
vae_idx,
ref_text_weight,
ref_vae_weight,
_EPS,
)
mot_out = mot_norm(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, tag)
# =========================================================================
# Test: und-mode (text_indices=None) — degrades to standard RMSNorm
# =========================================================================
@pytest.mark.parametrize(
"M, hidden_size, dtype",
[
(2048, 3584, torch.bfloat16),
(2048, 3584, torch.float16),
(2048, 128, torch.bfloat16),
],
ids=["M2048_H3584_bf16", "M2048_H3584_fp16", "M2048_H128_bf16"],
)
def test_mot_rms_norm_und_mode(M: int, hidden_size: int, dtype: torch.dtype):
"""und-mode: text_indices=None should apply self.weight to all tokens (standard RMSNorm)."""
torch.manual_seed(42)
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
with torch.no_grad():
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
mot_norm.weight.data.copy_(W_text)
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
with set_current_vllm_config(VllmConfig()):
with torch.no_grad():
ref = ir.ops.rms_norm(x, W_text, _EPS)
mot_out = mot_norm(x, text_indices=None, vae_indices=None)
_check_and_report(ref, mot_out, f"RMSNorm und-mode M={M} H={hidden_size}")
# =========================================================================
# Test: boundary cases (all-text, all-VAE)
# =========================================================================
@pytest.mark.parametrize(
"boundary_mode",
["all_text", "all_vae"],
ids=["all_text", "all_vae"],
)
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16], ids=["bf16", "fp16"])
def test_mot_rms_norm_boundary(boundary_mode: str, dtype: torch.dtype):
"""Boundary: all tokens routed to one expert."""
torch.manual_seed(42)
M = 1024
hidden_size = 3584
mot_norm = MoTRMSNorm(hidden_size, eps=_EPS).cuda()
with torch.no_grad():
W_text = torch.randn(hidden_size, dtype=dtype, device="cuda")
W_vae = torch.randn(hidden_size, dtype=dtype, device="cuda")
mot_norm.weight.data.copy_(W_text)
mot_norm.gen_weight.data.copy_(W_vae)
x = torch.randn(M, hidden_size, dtype=dtype, device="cuda")
all_indices = torch.arange(M, dtype=torch.long, device="cuda")
empty_indices = torch.empty(0, dtype=torch.long, device="cuda")
if boundary_mode == "all_text":
text_idx, vae_idx = all_indices, empty_indices
expected_weight = W_text
else:
text_idx, vae_idx = empty_indices, all_indices
expected_weight = W_vae
with set_current_vllm_config(VllmConfig()):
with torch.no_grad():
ref = ir.ops.rms_norm(x, expected_weight, _EPS)
mot_out = mot_norm(x, text_idx, vae_idx)
_check_and_report(ref, mot_out, f"RMSNorm boundary={boundary_mode} M={M}")
@@ -19,15 +19,18 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
NUM_TOKENS = 8
HIDDEN_DIM = 16
NUM_TIMESTEPS = 5
# generate_image samples (num_timesteps + 1) points then drops the last, matching
# upstream lance: see Bagel.generate_image in bagel_transformer.py.
EXPECTED_STEPS = NUM_TIMESTEPS
# Official BAGEL samples num_timesteps points then drops the terminal t=0,
# yielding num_timesteps - 1 denoise steps.
EXPECTED_STEPS = NUM_TIMESTEPS - 1
def _make_mock_bagel(mocker: MockerFixture):
"""Create a mock Bagel with forward returning constant velocity."""
mock = mocker.MagicMock(spec=Bagel)
mock._sp_size = 1
# MagicMock would otherwise auto-return a truthy stub for this flag; pin it
# to the official BAGEL schedule convention (num_timesteps points).
mock._denoise_schedule_extra_step = False
# forward returns a small constant velocity so x_t changes each step
def fake_forward(self, x_t, **kwargs):
@@ -179,7 +182,7 @@ class TestTrajectoryRecording:
bagel, args = bagel_and_args
# Recompute the expected timestep schedule (mirrors generate_image logic)
ts = torch.linspace(1, 0, args["num_timesteps"] + 1)
ts = torch.linspace(1, 0, args["num_timesteps"])
shift = args.get("timestep_shift", 1.0)
ts = shift * ts / (1 + (shift - 1) * ts)
expected_timesteps = ts[:-1] # last element is dropped
@@ -197,7 +200,7 @@ class TestTrajectoryRecording:
bagel, args = bagel_and_args
# Compute the number of denoising steps the same way generate_image does
ts = torch.linspace(1, 0, args["num_timesteps"] + 1)
ts = torch.linspace(1, 0, args["num_timesteps"])
shift = args.get("timestep_shift", 1.0)
ts = shift * ts / (1 + (shift - 1) * ts)
num_steps = len(ts) - 1 # timesteps = ts[:-1]
@@ -5,6 +5,7 @@ from __future__ import annotations
import sys
import types
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
@@ -230,6 +231,8 @@ def test_pipeline_registered_and_exported() -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin
from vllm_omni.diffusion.registry import (
_DIFFUSION_ACTION_POST_PROCESS_FUNCS,
_DIFFUSION_IR_OP_PRIORITY_FUNCS,
_DIFFUSION_MODELS,
_DIFFUSION_POST_PROCESS_FUNCS,
_DIFFUSION_PRE_PROCESS_FUNCS,
@@ -245,6 +248,10 @@ def test_pipeline_registered_and_exported() -> None:
)
assert _DIFFUSION_PRE_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_pre_process_func"
assert _DIFFUSION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_post_process_func"
assert (
_DIFFUSION_ACTION_POST_PROCESS_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_action_post_process_func"
)
assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["Cosmos3OmniDiffusersPipeline"] == "get_cosmos3_ir_op_priority_func"
assert "Cosmos3OmniDiffusersPipeline" in CUSTOM_DIT_ENABLERS
assert "Cosmos3OmniDiffusersPipeline" in cosmos3.__all__
@@ -413,6 +420,73 @@ def test_postprocess_handles_image_video_audio_and_validation() -> None:
func({"image": video, "video": video})
def test_action_postprocess_handles_robolab_policy_outputs() -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import (
RoboLabPolicyInputs,
get_cosmos3_action_post_process_func,
make_robolab_action_postprocess_inputs,
)
func = get_cosmos3_action_post_process_func(SimpleNamespace())
inputs = RoboLabPolicyInputs(
prompt="Pick the cube.",
video_tensor=torch.zeros(1, 3, 3, 16, 16),
action_tensor=torch.zeros(2, 2),
action_condition_indexes=[0],
action_start_frame_offset=1,
raw_action_dim=2,
domain_id=7,
fps=15.0,
height=16,
width=16,
image_size=None,
num_frames=3,
num_inference_steps=4,
guidance_scale=3.0,
flow_shift=5.0,
seed=11,
history_length=1,
action_space="joint_pos",
observation={},
)
action = torch.tensor([[[0.0, 0.25], [1.0, 0.75]]])
custom_output = {"robolab_action_postprocess": make_robolab_action_postprocess_inputs(inputs)}
processed = func(action, custom_output=custom_output)
assert processed.shape == (1, 2)
assert processed.dtype == torch.zeros((), dtype=torch.float32).numpy().dtype
torch.testing.assert_close(torch.from_numpy(processed), torch.tensor([[1.0, 0.25]]))
assert "robolab_action_postprocess" not in custom_output
def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.MonkeyPatch) -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import get_cosmos3_ir_op_priority_func
@dataclass
class FakeIrOpPriorityConfig:
rms_norm: list[str]
fused_add_rms_norm: list[str]
custom_op: list[str]
fake_kernel = types.ModuleType("vllm.config.kernel")
fake_kernel.IrOpPriorityConfig = FakeIrOpPriorityConfig
monkeypatch.setitem(sys.modules, fake_kernel.__name__, fake_kernel)
func = get_cosmos3_ir_op_priority_func(SimpleNamespace())
default_priority = FakeIrOpPriorityConfig(
rms_norm=["vllm_c", "native"],
fused_add_rms_norm=["vllm_c", "native"],
custom_op=["platform_kernel", "native"],
)
merged = func(default_priority, vllm_config=SimpleNamespace())
assert merged.rms_norm == ["native"]
assert merged.fused_add_rms_norm == ["native"]
assert merged.custom_op == ["platform_kernel", "native"]
def test_prompt_formatting_and_checkpoint_key_remap(make_cosmos3_pipeline) -> None:
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
@@ -762,6 +836,95 @@ class TestForwardRouting:
)
assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7]
assert output.custom_output["action"].shape == (1, 2, 2)
assert "action_only_output" not in output.custom_output
def test_forward_dispatches_robolab_policy_flow(
self,
make_cosmos3_pipeline,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
pipeline = make_cosmos3_pipeline()
pipeline.transformer = pipeline.transformer.__class__(latent_channel_size=2, action_gen=True, action_dim=4)
captured = self._install_forward_stubs(pipeline)
video_latents = torch.zeros(1, 2, 1, 1, 1)
velocity_mask = torch.ones(1, 1, 1, 1, 1)
condition_latents = torch.zeros_like(video_latents)
inputs = pipeline_cosmos3.RoboLabPolicyInputs(
prompt="Pick the cube.",
video_tensor=torch.zeros(1, 3, 3, 16, 16),
action_tensor=torch.zeros(2, 2),
action_condition_indexes=[0],
action_start_frame_offset=1,
raw_action_dim=2,
domain_id=7,
fps=15.0,
height=16,
width=16,
image_size=None,
num_frames=3,
num_inference_steps=4,
guidance_scale=3.0,
flow_shift=5.0,
seed=11,
history_length=1,
action_space="joint_pos",
observation={},
)
def fake_prepare_action_latents(**kwargs):
captured["prepare_action"] = kwargs
action_chunk_size = kwargs["action_chunk_size"]
raw_action_dim = int(kwargs["raw_action_dim"])
return (
torch.zeros(1, action_chunk_size, 4),
torch.ones(1, action_chunk_size, 1),
torch.zeros(1, action_chunk_size, 4),
raw_action_dim,
)
def fake_prepare_action_video(*args, **kwargs):
captured["prepare_action_video"] = {"args": args, "kwargs": kwargs}
return video_latents, velocity_mask, condition_latents
monkeypatch.setattr(
pipeline_cosmos3,
"build_robolab_unipc_scheduler",
lambda num_steps, shift, device: StubScheduler(list(range(num_steps, 0, -1)), flow_shift=shift),
)
pipeline._build_robolab_policy_inputs = lambda sp, prompt_data, request_id=None: inputs
pipeline._prepare_action_latents = fake_prepare_action_latents
pipeline._prepare_latents_action_video = fake_prepare_action_video
pipeline._decode_latents = lambda latents: (_ for _ in ()).throw(
AssertionError("RoboLab should not decode video")
)
output = pipeline.forward(SimpleNamespace(prompts=["ignored"], sampling_params=make_sampling_params()))
assert captured["format"] == {
"prompt": "Pick the cube.",
"negative_prompt": "",
"num_frames": 3,
"frame_rate": 15.0,
"height": 16,
"width": 16,
"is_t2i": False,
}
assert "flow_shifts" not in captured
assert pipeline.scheduler.set_timesteps_calls == []
assert captured["prepare_action"]["clean_action"] is inputs.action_tensor
assert captured["prepare_action"]["condition_indexes"] == [0]
assert captured["prepare_action_video"]["kwargs"] == {"image_size": None}
assert captured["diffuse_calls"][-1]["shared_kwargs"]["action_domain_ids"].tolist() == [7]
assert captured["diffuse_calls"][-1]["timesteps"].tolist() == [4, 3, 2, 1]
assert output.output == {}
assert output.custom_output["action_only_output"] is True
assert output.custom_output["action"].shape == (1, 2, 2)
assert "actions" not in output.custom_output
assert "robolab_action_postprocess" in output.custom_output
assert "robolab_policy_inputs" not in output.custom_output
@pytest.mark.parametrize(
("prompt", "sampling_params", "message"),
Whitespace-only changes.
@@ -0,0 +1,141 @@
from types import SimpleNamespace
import numpy as np
import pytest
from vllm_omni.diffusion.models.gr00t import pipeline_gr00t
from vllm_omni.diffusion.models.gr00t.pipeline_gr00t import Gr00tN1d7Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class FakeGr00tPolicy:
instances = []
def __init__(self, **kwargs):
self.kwargs = kwargs
self.reset_calls = 0
self.seen_obs = None
self.embodiment_tag = SimpleNamespace(value="fake_embodiment")
self.language_key = "annotation.language.language_instruction"
self.modality_configs = {
"action": SimpleNamespace(
delta_indices=[0, 1],
modality_keys=["arm", "gripper"],
)
}
self.processor = SimpleNamespace(
state_action_processor=SimpleNamespace(
norm_params={
"fake_embodiment": {
"action": {
"arm": {"dim": np.array(2)},
"gripper": {"dim": np.array(1)},
}
}
}
)
)
FakeGr00tPolicy.instances.append(self)
def get_action(self, obs):
self.seen_obs = obs
return {
"arm": np.array([[[1.0, 2.0]]], dtype=np.float64),
"gripper": [[[3.0]]],
}, {"latency_ms": 1.0}
def reset(self):
self.reset_calls += 1
return {"reset": True}
@pytest.fixture(autouse=True)
def fake_gr00t_policy(monkeypatch):
FakeGr00tPolicy.instances.clear()
monkeypatch.setattr(pipeline_gr00t, "Gr00tPolicy", FakeGr00tPolicy)
def _pipeline():
od_config = SimpleNamespace(
model="nvidia/GR00T-N1.7-3B",
model_config={
"embodiment_tag": "LIBERO_PANDA",
"strict": False,
},
custom_pipeline_args={},
)
return Gr00tN1d7Pipeline(od_config=od_config)
def test_pipeline_initializes_local_policy():
pipeline = _pipeline()
policy = FakeGr00tPolicy.instances[0]
assert policy.kwargs["model_path"] == "nvidia/GR00T-N1.7-3B"
assert policy.kwargs["embodiment_tag"] == "LIBERO_PANDA"
assert policy.kwargs["strict"] is False
assert pipeline.weights_sources == ()
assert pipeline.load_weights(iter(())) == set()
def test_forward_returns_dict_actions_in_output():
pipeline = _pipeline()
req = OmniDiffusionRequest(
prompts=["pick"],
request_id="req",
sampling_params=OmniDiffusionSamplingParams(
extra_args={
"robot_obs": {
"images": {"cam": np.zeros((1, 1, 8, 8, 3), dtype=np.uint8)},
"state": {"joint": np.zeros((1, 1, 2), dtype=np.float32)},
"prompt": "pick the cube",
"session_id": "session-a",
},
"reset": True,
}
),
)
output = pipeline.forward(req)
assert output.error is None
actions = output.output["actions"]
assert set(actions) == {"arm", "gripper"}
assert actions["arm"].dtype == np.float32
np.testing.assert_allclose(actions["arm"], np.array([[[1.0, 2.0]]], dtype=np.float32))
policy = FakeGr00tPolicy.instances[0]
assert "video" in policy.seen_obs
assert policy.seen_obs["language"] == {"annotation.language.language_instruction": [["pick the cube"]]}
assert "images" not in policy.seen_obs
assert "prompt" not in policy.seen_obs
assert "session_id" not in policy.seen_obs
assert policy.reset_calls == 1
def test_dummy_warmup_returns_shape_correct_zero_actions():
pipeline = _pipeline()
req = OmniDiffusionRequest(
prompts=["dummy run"],
request_id="dummy_req_id",
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1),
)
output = pipeline.forward(req)
assert output.error is None
actions = output.output["actions"]
assert set(actions) == {"arm", "gripper"}
assert actions["arm"].shape == (1, 2, 2)
assert actions["gripper"].shape == (1, 2, 1)
assert not actions["arm"].any()
assert FakeGr00tPolicy.instances[0].seen_obs is None
def test_reset_delegates_to_policy():
pipeline = _pipeline()
assert pipeline.reset() == {"reset": True}
assert FakeGr00tPolicy.instances[0].reset_calls == 1
@@ -0,0 +1,366 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
import vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 as hy3_module
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec
from vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 import (
_STEP_AR_KV,
_STEP_CFG_FACTOR,
_STEP_GENERATOR,
_STEP_GUIDANCE_SCALE,
_STEP_INPUT_IDS,
_STEP_MODEL_KWARGS,
_STEP_PROMPT_KV,
HunyuanImage3Pipeline,
)
from vllm_omni.diffusion.worker.input_batch import InputBatch
from vllm_omni.diffusion.worker.utils import DiffusionRequestState
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def _pipeline():
pipeline = object.__new__(HunyuanImage3Pipeline)
pipeline._tkwrapper = SimpleNamespace(pad_token_id=0)
pipeline.od_config = SimpleNamespace(
diffusion_attention_config=AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")),
parallel_config=SimpleNamespace(sequence_parallel_size=1, cfg_parallel_size=1),
cache_backend=None,
diffusion_kv_cache_skip_step_indices=None,
)
pipeline._pipeline = SimpleNamespace()
return pipeline
def _state(request_id: str, step_index: int) -> DiffusionRequestState:
state = DiffusionRequestState(
request_id=request_id,
sampling=SimpleNamespace(),
prompts=["prompt"],
)
state.step_index = step_index
state.timesteps = torch.tensor([1.0, 0.5, 0.25, 0.0])
state.latents = torch.zeros(1, 4, 8, 8)
state.extra = {
_STEP_CFG_FACTOR: 1,
_STEP_AR_KV: None,
_STEP_INPUT_IDS: None,
_STEP_GUIDANCE_SCALE: 1.0,
_STEP_MODEL_KWARGS: {
"num_image_tokens": 17,
"ar_kv_reuse_len": 0,
},
}
return state
def _sampling_params(**extra_args):
return SimpleNamespace(
timesteps=None,
sigmas=None,
num_outputs_per_prompt=None,
extra_args=extra_args,
height=512,
width=512,
num_inference_steps=4,
guidance_scale=1.0,
guidance_scale_provided=True,
guidance_rescale=0.0,
generator=None,
)
def test_hunyuan_step_group_key_ignores_step_index_for_later_steps():
pipeline = _pipeline()
states = [_state("req-0", 1), _state("req-1", 3)]
groups = pipeline._split_step_groups(states)
assert len(groups) == 1
assert [state.request_id for state in groups[0]] == ["req-0", "req-1"]
@pytest.mark.parametrize(
("sampling", "prompt_item", "expected_model_bot_task", "expected_system_bot_task"),
[
pytest.param(
_sampling_params(bot_task="think_recaption", use_system_prompt="dynamic"),
{"prompt": "prompt", "bot_task": "vanilla"},
"think",
"think",
id="extra-args-precedence",
),
pytest.param(
_sampling_params(use_system_prompt="dynamic"),
{"prompt": "prompt", "bot_task": "vanilla"},
"image",
"image",
id="prompt-dict-fallback",
),
pytest.param(
_sampling_params(use_system_prompt="dynamic"),
{"prompt": "prompt"},
"auto",
"image",
id="default-auto-system-prompt",
),
],
)
def test_prepare_encode_preserves_normal_hunyuan_bot_task_semantics(
monkeypatch,
sampling,
prompt_item,
expected_model_bot_task,
expected_system_bot_task,
):
pipeline = _pipeline()
captured: dict[str, object] = {}
def fake_get_system_prompt(sys_type, bot_task, system_prompt=None):
del sys_type, system_prompt
captured["system_prompt_bot_task"] = bot_task
return "system prompt"
def fake_prepare_model_inputs(**kwargs):
captured.update(kwargs)
raise RuntimeError("stop after prepare_model_inputs")
monkeypatch.setattr(hy3_module, "get_system_prompt", fake_get_system_prompt)
pipeline.prepare_model_inputs = fake_prepare_model_inputs
state = DiffusionRequestState(
request_id="req-bot-task",
sampling=sampling,
prompts=[prompt_item],
)
with pytest.raises(RuntimeError, match="stop after prepare_model_inputs"):
pipeline.prepare_encode(state)
assert captured["bot_task"] == expected_model_bot_task
assert captured["system_prompt_bot_task"] == expected_system_bot_task
def test_forward_uses_same_hunyuan_bot_task_semantics(monkeypatch):
pipeline = _pipeline()
captured: dict[str, object] = {}
def fake_get_system_prompt(sys_type, bot_task, system_prompt=None):
del sys_type, system_prompt
captured["system_prompt_bot_task"] = bot_task
return "system prompt"
def fake_prepare_model_inputs(**kwargs):
captured.update(kwargs)
raise RuntimeError("stop after prepare_model_inputs")
monkeypatch.setattr(hy3_module, "get_system_prompt", fake_get_system_prompt)
pipeline.prepare_model_inputs = fake_prepare_model_inputs
req = SimpleNamespace(
request_id="req-forward-bot-task",
sampling_params=_sampling_params(bot_task="think_recaption", use_system_prompt="dynamic"),
prompts=[{"prompt": "prompt", "bot_task": "vanilla"}],
)
with pytest.raises(RuntimeError, match="stop after prepare_model_inputs"):
pipeline.forward(req)
assert captured["bot_task"] == "think"
assert captured["system_prompt_bot_task"] == "think"
def test_grouped_denoise_rejects_non_sdpa_attention_backend():
pipeline = _pipeline()
pipeline.od_config.diffusion_attention_config = AttentionConfig(default=AttentionSpec(backend="FLASH_ATTN"))
with pytest.raises(ValueError, match="only supports TORCH_SDPA"):
pipeline._ensure_grouped_attention_backend_supported(2)
def test_single_denoise_allows_non_sdpa_attention_backend():
pipeline = _pipeline()
pipeline.od_config.diffusion_attention_config = AttentionConfig(default=AttentionSpec(backend="FLASH_ATTN"))
pipeline._ensure_grouped_attention_backend_supported(1)
def test_grouped_denoise_allows_sdpa_attention_backend():
pipeline = _pipeline()
pipeline._ensure_grouped_attention_backend_supported(2)
def test_step_scheduler_preserves_latent_dtype_for_mixed_progress_batches():
pipeline = _pipeline()
pipeline._pipeline = SimpleNamespace(prepare_extra_func_kwargs=lambda step, kwargs: {})
class FakeScheduler:
def step(self, noise_pred, timestep, latents, **kwargs):
del timestep, kwargs
return (latents.float() + noise_pred.float(),)
state = _state("req", 0)
state.timesteps = torch.tensor([1.0])
state.scheduler = FakeScheduler()
state.latents = torch.zeros(1, 4, 8, 8, dtype=torch.bfloat16)
state.extra[_STEP_GENERATOR] = None
pipeline.step_scheduler(state, torch.ones_like(state.latents, dtype=torch.float32))
assert state.latents.dtype == torch.bfloat16
assert state.step_index == 1
def test_later_step_merge_shifts_spans_without_polluting_request_state():
pipeline = _pipeline()
states = [_state("short", 2), _state("long", 4)]
states[0].extra[_STEP_MODEL_KWARGS].update(
{
"attention_mask": torch.ones(1, 1, 3, 5, dtype=torch.bool),
"full_attn_spans": [[(2, 5)]],
}
)
states[1].extra[_STEP_MODEL_KWARGS].update(
{
"attention_mask": torch.ones(1, 1, 3, 7, dtype=torch.bool),
"full_attn_spans": [[(4, 7)]],
}
)
states[0].extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([2])}]
states[1].extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([4])}]
row_state_indexes = [0, 1]
row_branches = [0, 0]
_, merged = pipeline._merge_step_model_inputs(
states,
row_state_indexes,
row_branches,
first_step=False,
)
assert merged["attention_mask"].shape == (2, 1, 3, 7)
assert merged["full_attn_spans"] == [[(4, 7)], [(4, 7)]]
pipeline._split_merged_kwargs_to_states(states, merged, row_state_indexes, row_branches)
assert states[0].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (1, 1, 3, 5)
assert states[1].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (1, 1, 3, 7)
assert states[0].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(2, 5)]]
assert states[1].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(4, 7)]]
def test_later_step_merge_allows_request_local_step_counts_and_guidance_values():
pipeline = _pipeline()
states = [_state("req-0", 1), _state("req-1", 3)]
for idx, state in enumerate(states):
state.extra[_STEP_MODEL_KWARGS].update(
{
"attention_mask": torch.ones(1, 1, 2, 4, dtype=torch.bool),
"full_attn_spans": [[(2, 4)]],
"guidance_scale": 3.0 + idx,
"num_inference_steps": 20 + idx,
}
)
state.extra[_STEP_PROMPT_KV] = [{"lens": torch.tensor([2])}]
_, merged = pipeline._merge_step_model_inputs(
states,
row_state_indexes=[0, 1],
row_branches=[0, 0],
first_step=False,
)
assert "guidance_scale" not in merged
assert "num_inference_steps" not in merged
@pytest.mark.parametrize(
("request_id", "mutate_state", "error_match"),
[
pytest.param(
"broken-req",
lambda state: state.extra.pop(_STEP_MODEL_KWARGS),
"broken-req",
id="missing-model-kwargs",
),
pytest.param(
"bad-cfg",
lambda state: state.extra.__setitem__(_STEP_CFG_FACTOR, 3),
"bad-cfg",
id="unsupported-cfg-factor",
),
],
)
def test_denoise_step_reports_invalid_group_state_with_request_id(request_id, mutate_state, error_match):
pipeline = _pipeline()
state = _state(request_id, 0)
mutate_state(state)
with pytest.raises(ValueError, match=error_match):
pipeline.denoise_step(InputBatch.make_batch([state]))
def test_denoise_step_uses_input_batch_group_order_and_splits_back(monkeypatch):
pipeline = _pipeline()
monkeypatch.setattr(HunyuanImage3Pipeline, "device", property(lambda self: torch.device("cpu")))
states = [_state("req-0", 1), _state("req-1", 3)]
for idx, state in enumerate(states):
prefix_len = 2 + idx * 2
state.latents = torch.full((1, 1), float(idx))
state.extra[_STEP_CFG_FACTOR] = 2
state.extra[_STEP_GUIDANCE_SCALE] = 1.0
state.extra[_STEP_INPUT_IDS] = None
state.extra[_STEP_MODEL_KWARGS].update(
{
"attention_mask": torch.ones(2, 1, 2, prefix_len + 2, dtype=torch.bool),
"full_attn_spans": [[(prefix_len, prefix_len + 2)], [(prefix_len, prefix_len + 2)]],
}
)
state.extra[_STEP_PROMPT_KV] = [
{
"key": torch.zeros(2, prefix_len, 1, 1),
"value": torch.zeros(2, prefix_len, 1, 1),
"lens": torch.tensor([prefix_len, prefix_len]),
}
]
captured = {}
def fake_restore_prompt_kv_cache(states_arg, row_state_indexes, row_branches):
del states_arg
captured["row_state_indexes"] = list(row_state_indexes)
captured["row_branches"] = list(row_branches)
def fake_prepare_inputs_for_generation(input_ids, images, timestep, **model_kwargs):
captured["input_ids"] = input_ids
captured["images"] = images.clone()
captured["timestep"] = timestep.clone()
captured["merged_attention_mask_shape"] = tuple(model_kwargs["attention_mask"].shape)
captured["merged_full_attn_spans"] = model_kwargs["full_attn_spans"]
return {"model_kwargs": model_kwargs}
pipeline._restore_prompt_kv_cache = fake_restore_prompt_kv_cache
pipeline.prepare_inputs_for_generation = fake_prepare_inputs_for_generation
pipeline.forward_call = lambda **kwargs: {"diffusion_prediction": torch.tensor([[10.0], [20.0], [1.0], [2.0]])}
pipeline._update_model_kwargs_for_generation = lambda model_output, model_kwargs: model_kwargs
pipeline._pipeline = SimpleNamespace(cfg_operator=lambda cond, uncond, scale, step: cond + uncond)
batch = InputBatch.make_batch(states)
out = pipeline.denoise_step(batch)
assert captured["row_state_indexes"] == [0, 1, 0, 1]
assert captured["row_branches"] == [0, 0, 1, 1]
assert captured["input_ids"] is None
assert tuple(captured["images"].shape) == (4, 1)
assert captured["timestep"].tolist() == [0.5, 0.0, 0.5, 0.0]
assert captured["merged_attention_mask_shape"] == (4, 1, 2, 6)
assert captured["merged_full_attn_spans"] == [[(4, 6)], [(4, 6)], [(4, 6)], [(4, 6)]]
torch.testing.assert_close(out, torch.tensor([[11.0], [22.0]]))
assert states[0].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (2, 1, 2, 4)
assert states[1].extra[_STEP_MODEL_KWARGS]["attention_mask"].shape == (2, 1, 2, 6)
assert states[0].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(2, 4)], [(2, 4)]]
assert states[1].extra[_STEP_MODEL_KWARGS]["full_attn_spans"] == [[(4, 6)], [(4, 6)]]
@@ -692,16 +692,27 @@ class TestRegistryIntegration:
assert name in _DIFFUSION_POST_PROCESS_FUNCS, f"{name} not in _DIFFUSION_POST_PROCESS_FUNCS"
assert _DIFFUSION_POST_PROCESS_FUNCS[name] == "get_ltx2_post_process_func"
def test_cache_dit_enablers_registered(self):
"""Pipeline variants must be registered in CUSTOM_DIT_ENABLERS."""
def test_cache_dit_for_ltx2_does_not_have_custom_enablers_registered(self):
"""Pipeline variants are *not* registered in CUSTOM_DIT_ENABLERS."""
from vllm_omni.diffusion.cache.cache_dit_backend import CUSTOM_DIT_ENABLERS
# NOTE: We used to have custom enablers for this model, but refactored to handle
# it more generically. Now we only need to ensure it has git cache adapter config.
expected = [
"LTX23Pipeline",
"LTX23ImageToVideoPipeline",
]
for name in expected:
assert name in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS"
assert name not in CUSTOM_DIT_ENABLERS, f"{name} not in CUSTOM_DIT_ENABLERS"
def test_ltx2_transformer_has_dit_cache_config(self):
"""Ensure LTX2 has a Cache DiT adapter config and that it uses separate CFG."""
from vllm_omni.diffusion.cache.cache_dit_backend import CacheDiTAdapterConfig
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
adapter_config = getattr(LTX2VideoTransformer3DModel, "_cache_dit_adapter_config")
assert isinstance(adapter_config, CacheDiTAdapterConfig)
assert adapter_config.has_separate_cfg
class TestVocoderSampleRateDetection:
@@ -0,0 +1,44 @@
"""Regression tests for #4443: padded prompt width vs RoPE table length.
When ``max(txt_seq_lens) < prompt_embeds.shape[1]``, ``QwenEmbedRope`` builds a
text frequency table that is too short for the padded encoder width.
"""
import pytest
import torch
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope
from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Representative request-batch padding: valid tokens 10/20, shared width 32.
PADDED_WIDTH = 32
VALID_LENS = [10, 20]
def test_pre_pr_txt_seq_lens_is_shorter_than_padded_width():
mask = torch.zeros(len(VALID_LENS), PADDED_WIDTH, dtype=torch.bool)
mask[0, : VALID_LENS[0]] = True
mask[1, : VALID_LENS[1]] = True
pre_pr_txt_seq_lens = mask.sum(dim=1).tolist()
assert pre_pr_txt_seq_lens == VALID_LENS
assert max(pre_pr_txt_seq_lens) < PADDED_WIDTH
def test_post_pr_txt_seq_lens_matches_padded_width():
prompt_embeds = torch.zeros(len(VALID_LENS), PADDED_WIDTH, 8)
assert txt_seq_lens_from_embeds(prompt_embeds) == [PADDED_WIDTH, PADDED_WIDTH]
def test_rope_table_length_matches_padded_width_after_fix():
rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True)
img_shapes = [[(1, 16, 16)]]
_, freqs_pre_pr = rope(img_shapes, VALID_LENS, device="cpu")
_, freqs_post_pr = rope(img_shapes, [PADDED_WIDTH, PADDED_WIDTH], device="cpu")
assert freqs_pre_pr.shape[0] == max(VALID_LENS)
assert freqs_pre_pr.shape[0] < PADDED_WIDTH
assert freqs_post_pr.shape[0] == PADDED_WIDTH
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff. Show more